Service Worker Fundamentals
Master MV3 service worker registration, the install/activate/idle lifecycle, event-driven design, state hydration from chrome.storage, and alarm-based scheduling.
Master MV3 service worker registration, the install/activate/idle lifecycle, event-driven design, state hydration from chrome.storage, and alarm-based scheduling.
The moment you move from MV2 to Manifest V3, your background page becomes an ephemeral service worker that the browser can evict after roughly 30 seconds of inactivity — taking every in-memory variable with it. That constraint shapes every architectural decision covered in this guide, which is part of the Manifest V3 Architecture & Extension Lifecycle section. Get the lifecycle model right first, and reliable cross-context messaging and scheduled tasks follow naturally. Start with the wrong mental model and you will chase ghost bugs caused by state that silently disappears.
"background": { "service_worker": "background.js" } declared in manifest.json."storage" permission listed — every chrome.storage.* call throws without it."alarms" permission listed if you use chrome.alarms for scheduled work.async function or wrapped in a conditional that evaluates after the first await.The background.service_worker key in manifest.json is the only registration you write. The browser handles the actual navigator.serviceWorker.register() behind the scenes.
1{
2 "manifest_version": 3,
3 "name": "My Extension",
4 "version": "1.0.0",
5 "background": {
6 "service_worker": "background.js", // single entry point
7 "type": "module" // enables ES module imports
8 },
9 "permissions": ["storage", "alarms"]
10}
Execution context: Parsed by the extension host at install/update time. The "type": "module" flag is supported on Chrome 116+ and Firefox MV3; Safari requires it from Safari 16.4. Without it, import statements throw a syntax error at parse time.
The worker fires chrome.runtime.onInstalled once per install or update, making it the right place to seed default state. After that, each wake cycle starts the worker cold with no memory of previous runs.
1// background.js — top-level, synchronous listener registration
2chrome.runtime.onInstalled.addListener(async ({ reason }) => {
3 if (reason === chrome.runtime.OnInstalledReason.INSTALL) {
4 // Seed defaults only on first install
5 await chrome.storage.local.set({
6 schemaVersion: 1,
7 enabled: true,
8 lastSync: 0,
9 });
10 // Create a recurring alarm — survives worker eviction
11 await chrome.alarms.create('periodic-sync', { periodInMinutes: 15 });
12 }
13});
14
15chrome.runtime.onStartup.addListener(async () => {
16 // Fires when the browser profile loads, not on every worker wake
17 const { lastSync } = await chrome.storage.local.get('lastSync');
18 console.debug('[sw] browser startup, lastSync=', lastSync);
19});
Execution context: Runs in the extension service worker (WorkerGlobalScope). No window, document, or localStorage. Both callbacks are registered synchronously before any await; the runtime captures them during the initial synchronous evaluation pass.
Chrome terminates a service worker after approximately 30 seconds with no active event. This is a hard limit enforced by the browser scheduler — it is not configurable, and there is no keepAlive flag. Firefox is more lenient in practice but still terminates workers without active events. Safari mirrors Chrome’s strict policy.
The consequences are direct:
let cache = {} is wiped at eviction.setInterval registered inside the worker is silently cancelled.fetch that outlasts the active event may or may not complete.Design around this by treating every event handler as if it starts from scratch. Read needed state at the top of the handler, write mutated state before returning. The guide on keeping service workers alive during long tasks covers the few legitimate techniques for extending the window.
The single most common MV3 mistake: registering a listener inside an async function or after an await. The runtime’s event-dispatch system captures listeners during the initial synchronous evaluation of the worker script. Any listener registered after the script’s first microtask checkpoint is not guaranteed to be captured for events that arrive while the worker is starting up.
1// WRONG — listener may be missed on cold start
2chrome.runtime.onInstalled.addListener(async () => {
3 await doSetup();
4 chrome.runtime.onMessage.addListener(handleMessage); // TOO LATE
5});
6
7// CORRECT — all listeners at top level, synchronously
8chrome.runtime.onMessage.addListener(handleMessage);
9chrome.runtime.onInstalled.addListener(async () => {
10 await doSetup();
11});
12chrome.alarms.onAlarm.addListener(handleAlarm);
13chrome.action.onClicked.addListener(handleClick);
14
15async function handleMessage(msg, sender, sendResponse) {
16 // reads state fresh on each invocation
17}
Execution context: Extension service worker. The synchronous evaluation window is the few milliseconds between script load and the first microtask. Chrome, Firefox, and Safari all enforce this rule.
Because every cold start is a blank slate, the pattern for durable state is: read at the start of a handler, write at the end. Never rely on a module-level variable as a cache across evictions.
1chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
2 if (msg.type === 'GET_STATUS') {
3 (async () => {
4 // Hydrate on demand — no warm cache assumed
5 const { enabled, lastSync } = await chrome.storage.local.get([
6 'enabled',
7 'lastSync',
8 ]);
9 sendResponse({ enabled, lastSync });
10 })();
11 return true; // mandatory: keeps the message channel open for async response
12 }
13});
Execution context: Extension service worker. chrome.storage.local is accessible from every extension context. return true from an onMessage listener is required when sendResponse is called asynchronously; without it the port closes before the callback fires. Firefox exposes the same API under browser.storage.local with native Promises; on Chrome both the callback and Promise forms work in MV3.
setInterval and setTimeout must not be used for background scheduling in MV3. The worker terminates before most long-running timers fire. chrome.alarms is the correct replacement: the alarm is stored by the browser, fires even when the worker is evicted, and wakes the worker to handle it.
1// Register the alarm once (onInstalled or on first run)
2chrome.runtime.onInstalled.addListener(async () => {
3 const existing = await chrome.alarms.get('data-sync');
4 if (!existing) {
5 await chrome.alarms.create('data-sync', { periodInMinutes: 15 });
6 }
7});
8
9// Handle it at top level
10chrome.alarms.onAlarm.addListener(async (alarm) => {
11 if (alarm.name !== 'data-sync') return;
12 try {
13 const payload = await fetchRemoteConfig();
14 await chrome.storage.local.set({ config: payload, lastSync: Date.now() });
15 } catch (err) {
16 console.error('[sw] sync failed', err);
17 }
18});
Execution context: Extension service worker. chrome.alarms requires the "alarms" permission. Minimum alarm period in Chrome and Edge is 1 minute (enforced after the first five alarms in a session). Firefox respects sub-minute periods in development but caps them to 1 minute in production. Safari may delay alarms by several seconds under battery-saving modes.
localStorage, sessionStorage, or window — use chrome.storage.local / .session / .sync.setInterval / setTimeout for background scheduling — use chrome.alarms.chrome.offscreen is Chrome/Edge only — Firefox and Safari do not support it; use content scripts or the scripting API as fallbacks.| Behaviour | Chrome / Edge | Firefox | Safari |
|---|---|---|---|
| Idle eviction | ~30 s, strict | Lenient, but non-deterministic | ~30 s, strict |
| Alarm minimum period | 1 min (after first 5) | Sub-minute in dev, 1 min in prod | 1 min; may drift under power saving |
"type": "module" support | Chrome 116+ | Firefox 101+ | Safari 16.4+ |
chrome.offscreen | Yes | No | No |
| Namespace | chrome.* | browser.* or chrome.* polyfill | browser.* (Safari 14+) |
onSuspend hint | No | browser.runtime.onSuspend | No |
Firefox’s MV3 implementation still ships some MV2 carryover behaviours. Always test with the WebExtensions polyfill (webextension-polyfill) to normalise namespace and Promise handling across all three engines.
This section contains three in-depth guides for the most common service worker tasks:
Legitimate techniques for extending an MV3 service worker beyond the 30-second idle limit: alarm chunking, offscreen documents, port keepalive, waitUntil, and when to avoid all of them.
Step-by-step guide to replacing MV2 background pages with MV3 service workers: manifest changes, storage migration, alarm scheduling, and cross-browser gotchas.
Understand why MV3 service workers are non-persistent, what state loss looks like in practice, and how to design around the ~30-second idle eviction with chrome.storage hydration.