Service Workers Kill Global State
In Manifest V2, background scripts ran persistently. You could define let userSettings = {} and access it anytime. In MV3, the service worker (SW) can be terminated between events. That global variable disappears.
The fix: use chrome.storage.local for all persistent state. Every read/write becomes async, which forces a rethinking of code structure. The hidden benefit: crash recovery is free—if the browser restarts, your state survives.
// MV3 — everything that needs to survive goes to storage
async function startTimer(tabId) {
const { tabTimers = {} } = await chrome.storage.local.get('tabTimers');
tabTimers[tabId] = tabTimers[tabId] || { start: Date.now(), elapsed: 0 };
await chrome.storage.local.set({ tabTimers });
}
setInterval in Background Is Unreliable—Use Alarms
setInterval in an MV3 SW runs until Chrome kills the SW—could be 30 seconds or 5 minutes. For periodic tasks, use chrome.alarms.
chrome.runtime.onInstalled.addListener(() => {
chrome.alarms.create('costTick', { periodInMinutes: 1 });
});
chrome.alarms.onAlarm.addListener(async (alarm) => {
if (alarm.name === 'costTick') {
await updateAllTabCosts();
}
});
Critical limitation: minimum alarm period is 1 minute. For sub-minute updates, the popup can run its own setInterval while open, and persist state on close.
IndexedDB for Large Datasets
chrome.storage.local has a 10MB limit (configurable with unlimitedStorage permission, but requires justification). IndexedDB handles gigabytes and is transactional. Access from SW was buggy until Chrome 102—test explicitly.
export function openDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open('PRFocusDB', 1);
request.onupgradeneeded = (e) => {
const db = e.target.result;
if (!db.objectStoreNames.contains('prData')) {
const store = db.createObjectStore('prData', { keyPath: 'id' });
store.createIndex('repo', 'repo', { unique: false });
store.createIndex('riskScore', 'riskScore', { unique: false });
}
};
request.onsuccess = (e) => resolve(e.target.result);
request.onerror = (e) => reject(e.target.error);
});
}
Stricter CSP: No eval, No Remote Scripts
MV3 prohibits dynamic code execution from remote sources. Avoid innerHTML—use DOM APIs instead to prevent XSS.
function createPRCard(pr) {
const card = document.createElement('div');
card.className = 'pr-card';
card.dataset.id = pr.id;
const title = document.createElement('h3');
title.textContent = pr.title;
const score = document.createElement('span');
score.className = 'score';
score.textContent = pr.score;
card.appendChild(title);
card.appendChild(score);
return card;
}
Message Passing Between SW and Popup
In MV3, the SW and popup are separate contexts. Use chrome.runtime.connect for persistent connections when you need streaming.
// In the popup
const port = chrome.runtime.connect({ name: 'prAnalysis' });
port.onMessage.addListener((msg) => {
if (msg.type === 'progress') updateProgressBar(msg.percent);
if (msg.type === 'complete') displayResults(msg.data);
});
port.postMessage({ type: 'start', repos: selectedRepos });
// In the service worker
chrome.runtime.onConnect.addListener((port) => {
if (port.name === 'prAnalysis') {
port.onMessage.addListener(async (msg) => {
if (msg.type === 'start') {
for (const repo of msg.repos) {
await analyzeRepo(repo, (progress) => {
port.postMessage({ type: 'progress', percent: progress });
});
}
port.postMessage({ type: 'complete', data: results });
}
});
}
});
What MV3 Gives You
- Better memory behavior: idle SWs use no RAM.
- Forced architectural discipline: explicit state management, clear separation of concerns, proper async handling.
- Firefox compatibility: Firefox adopted MV3 (with differences). Building MV3-native simplifies porting.
The One Thing to Know
Read the Service Worker lifecycle documentation before writing background code. The SW can be terminated between events—understanding this changes everything.
The author's extensions (PR Focus Pro and TabCost Pro) and detailed build logs are available on DEV Community.
