The biggest trap when moving a background script to a service worker is assuming you can swap the manifest key and stop there. The extension will load — and then silently lose state, miss events, and fail on the first cold start after the worker is evicted. This guide works through every breaking change in order of impact. For the full lifecycle model that underpins these changes, see Service Worker Fundamentals.
Root cause
MV2’s persistent: true background page kept a live V8 context running for the entire browser session. Global variables, open ports, and setInterval callbacks persisted indefinitely. MV3 replaces that with a service worker that the browser can terminate after approximately 30 seconds of inactivity. On the next event, the worker boots from scratch: a fresh V8 context, no memory of previous globals, no running timers. Every pattern that relies on process-lifetime continuity breaks.
Step 1. Update manifest.json
Remove the background.scripts array and persistent flag. Add a single service_worker string. The browser registers and manages the worker automatically.
1// manifest.json — MV2 (remove this)
2{
3 "manifest_version": 2,
4 "background": {
5 "scripts": ["background.js"],
6 "persistent": true
7 }
8}
Execution context: This is a static JSON file parsed by the extension host. Any parse error or unknown key causes the extension to fail to load entirely — validate with chrome://extensions after each change.
1// manifest.json — MV3 (replace with this)
2{
3 "manifest_version": 3,
4 "background": {
5 "service_worker": "background.js",
6 "type": "module" // enables ES module imports; supported Chrome 116+, Firefox 101+, Safari 16.4+
7 },
8 "permissions": ["storage", "alarms"]
9}
Execution context: "type": "module" allows import / export in the worker. Omit it if you bundle everything into a single file — the bundler eliminates the need.
Step 2. Replace localStorage and DOM globals
The service worker runs in WorkerGlobalScope, not Window. Accessing window, document, or localStorage throws a ReferenceError immediately. All synchronous storage must move to chrome.storage.local or chrome.storage.session.
1// MV2 — synchronous, throws in MV3
2const theme = localStorage.getItem('theme') ?? 'light';
3localStorage.setItem('theme', 'dark');
4
5// MV3 — async, Promise-based
6const { theme } = await chrome.storage.local.get({ theme: 'light' }); // default value inline
7await chrome.storage.local.set({ theme: 'dark' });
Execution context: Extension service worker. chrome.storage.local is accessible from every extension context including content scripts and popups. If you need data that only survives until the worker terminates, use chrome.storage.session (Chrome 102+, Firefox 115+).
Choose the right storage area:
| Need | API |
|---|
| Survive worker eviction and browser restart | chrome.storage.local |
| Cross-device sync (small payloads, ≤100 KB) | chrome.storage.sync |
| Tab-session cache, cleared on browser close | chrome.storage.session |
| Large binary data, structured queries | IndexedDB |
Step 3. Move all listeners to the top level
This is the most common post-migration bug. The runtime captures event listeners during the synchronous evaluation of the worker script. Any listener registered after an await — or nested inside another async callback — may not be captured when the worker wakes for a new event.
1// MV2 pattern — worked because the page was always alive
2chrome.runtime.onInstalled.addListener(async () => {
3 await loadConfig();
4 chrome.runtime.onMessage.addListener(handleMsg); // FAILS in MV3 cold-start
5});
6
7// MV3 — all listeners at top level, before any await
8chrome.runtime.onMessage.addListener(handleMsg);
9chrome.alarms.onAlarm.addListener(handleAlarm);
10chrome.action.onClicked.addListener(handleClick);
11chrome.runtime.onInstalled.addListener(handleInstall); // can be async internally
12
13async function handleInstall({ reason }) {
14 if (reason === 'install') {
15 await chrome.storage.local.set({ schemaVersion: 2, enabled: true });
16 await chrome.alarms.create('sync', { periodInMinutes: 15 });
17 }
18}
Execution context: Extension service worker. The synchronous evaluation window is the few milliseconds between script load and the first resolved microtask. All three browsers — Chrome, Firefox, Safari — enforce this rule identically.
Step 4. Replace setInterval / setTimeout with chrome.alarms
setInterval callbacks that outlast the 30-second idle window are silently dropped. chrome.alarms stores the schedule in the browser, survives worker eviction, and wakes the worker when the alarm fires.
1// MV2 — fails silently when worker is evicted
2setInterval(() => syncRemoteConfig(), 15 * 60 * 1000);
3
4// MV3 — create the alarm once, handle at top level
5chrome.runtime.onInstalled.addListener(async () => {
6 const existing = await chrome.alarms.get('config-sync');
7 if (!existing) {
8 await chrome.alarms.create('config-sync', { periodInMinutes: 15 });
9 }
10});
11
12chrome.alarms.onAlarm.addListener(async (alarm) => {
13 if (alarm.name !== 'config-sync') return;
14 try {
15 const config = await fetchRemoteConfig();
16 await chrome.storage.local.set({ config, lastSync: Date.now() });
17 } catch (err) {
18 console.error('[sw] config sync failed:', err);
19 }
20});
Execution context: Extension service worker. chrome.alarms requires the "alarms" permission. Chrome enforces a minimum period of 1 minute after the first five alarms in a session. Firefox respects sub-minute periods during development. For the advanced techniques around keeping workers active during longer operations, see keeping service workers alive during long tasks.
Step 5. Handle async sendResponse correctly
In MV2, chrome.runtime.onMessage listeners could return a Promise or call sendResponse any time. In MV3, returning a Promise from an onMessage listener does not keep the channel open — you must return the literal boolean true to signal that sendResponse will be called asynchronously.
1// Correct MV3 async message handler
2chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
3 if (msg.type === 'FETCH_DATA') {
4 (async () => {
5 try {
6 const data = await chrome.storage.local.get(msg.key);
7 sendResponse({ ok: true, data });
8 } catch (err) {
9 sendResponse({ ok: false, error: err.message });
10 }
11 })();
12 return true; // REQUIRED — keeps the port open until sendResponse fires
13 }
14 // For sync responses, return nothing (or false) — port closes immediately
15});
Execution context: Extension service worker. If return true is omitted, the port closes before the async IIFE resolves, and sendResponse silently no-ops. Firefox’s MV3 implementation accepts a returned Promise from the listener as an alternative to return true, but Chrome does not — use return true for cross-browser safety.
Step 6. Rehydrate state on every cold start
Because module-scope variables reset on eviction, every handler that needs state must read it from chrome.storage at invocation time. Do not assume a module-level cache is warm.
1// Pattern: read → process → write on every handler invocation
2chrome.alarms.onAlarm.addListener(async (alarm) => {
3 if (alarm.name !== 'badge-updater') return;
4 // Hydrate — do not rely on a module-level variable
5 const { unreadCount } = await chrome.storage.local.get({ unreadCount: 0 });
6 const updated = await pollUnread();
7 await chrome.storage.local.set({ unreadCount: updated });
8 await chrome.action.setBadgeText({ text: updated > 0 ? String(updated) : '' });
9});
Execution context: Extension service worker. chrome.action.setBadgeText is available in the worker context. The read-process-write pattern is the correct model for non-persistent workers; see persistent vs non-persistent service workers explained for the full contrast.
Cross-browser variation
- Chrome / Edge: Strict 30-second idle eviction.
chrome.offscreen available for DOM tasks. No onSuspend hook. - Firefox: MV3 support is production-ready from Firefox 109. The
browser.* namespace is preferred; the chrome.* alias works too. Firefox may allow longer idle windows but do not depend on this. browser.runtime.onSuspend fires before eviction — use it to flush pending writes. - Safari: Mirrors Chrome’s strict policy. Requires
browser.* namespace (Safari 14+). Alarms may drift by several seconds under battery-saving modes. chrome.offscreen is not supported.
Verification
- Open
chrome://extensions, click Inspect views → service worker for your extension. - Trigger any event (click the action button, or send a message from the extension popup).
- Confirm the console logs from your handlers appear with no
ReferenceError or undefined variable errors. - Wait 35 seconds without triggering anything. The worker should show as stopped in
chrome://extensions. - Trigger an event again. Confirm handlers fire and state is correctly re-read from
chrome.storage. - Open Application → Service Workers in DevTools and verify the worker status toggles between
running and stopped.
FAQ
Why does my listener fire on first load but stop working after the worker sleeps?
The listener was registered inside an async callback or after an await. On cold start the script re-evaluates synchronously, the await yields, and the listener is not captured before the first incoming event is dispatched. Move every addListener call to the top level of the script.
Can I use async/await at the top level of the worker file?
Top-level await pauses script evaluation and delays listener registration — which means events that arrive during the pause may be missed. Avoid top-level await for anything that precedes listener registration. It is safe after all listeners are registered, though it is rarely needed.
My fetch inside a handler gets interrupted. How do I fix it?
The worker can be evicted if the event handler’s promise chain does not hold an active event. Wrap your async logic inside the listener callback itself (as shown in Step 5’s IIFE pattern), and ensure the fetch either completes within the active window or is re-attempted via chrome.alarms. For large downloads or multi-step network tasks, read the guide on keeping service workers alive during long tasks.
Does chrome.runtime.onInstalled still fire in MV3?
Yes. It fires once per install, once per extension update, and once per Chrome update. It does not fire on every worker cold start — use chrome.runtime.onStartup for browser-restart initialisation and rely on chrome.storage for per-wake hydration.