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.

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.

MV3 service worker lifecycle and state flowThe service worker moves through install, activate, idle, and running states. chrome.storage provides durable state across evictions; chrome.alarms wakes the worker on schedule.InstallonInstalled firesActivateseed storageRunningevent handler activeIdle / Evicted~30 s, memory freedevent or alarm wakes workerchrome.storagedurable across evictionschrome.alarmsschedule without setIntervalread / write statefires scheduled task

Prerequisites checklist

  • "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.
  • All event listeners registered synchronously at the top level of the worker file — never inside an async function or wrapped in a conditional that evaluates after the first await.
  • A plan for re-hydrating state on cold start, because a worker can be evicted at any moment.

1. Registering the service worker

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.

2. The install/activate lifecycle

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.

3. Idle termination and the ~30-second window

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:

  • A module-scope variable like let cache = {} is wiped at eviction.
  • A setInterval registered inside the worker is silently cancelled.
  • An in-flight 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.

4. Top-level listener registration

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.

5. State hydration from chrome.storage

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.

6. Alarm-based scheduling

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.

MV3 Constraints box

  • 30-second idle eviction is not configurable. Every handler must be self-contained.
  • No localStorage, sessionStorage, or window — use chrome.storage.local / .session / .sync.
  • No setInterval / setTimeout for background scheduling — use chrome.alarms.
  • Top-level listener registration is mandatory — async-wrapped listeners are not reliably captured.
  • No shared memory across wake cycles — treat module-scope variables as write-once-per-wake scratch space.
  • Unhandled rejections terminate the worker — wrap every async handler in try/catch.
  • chrome.offscreen is Chrome/Edge only — Firefox and Safari do not support it; use content scripts or the scripting API as fallbacks.

Cross-browser notes

BehaviourChrome / EdgeFirefoxSafari
Idle eviction~30 s, strictLenient, but non-deterministic~30 s, strict
Alarm minimum period1 min (after first 5)Sub-minute in dev, 1 min in prod1 min; may drift under power saving
"type": "module" supportChrome 116+Firefox 101+Safari 16.4+
chrome.offscreenYesNoNo
Namespacechrome.*browser.* or chrome.* polyfillbrowser.* (Safari 14+)
onSuspend hintNobrowser.runtime.onSuspendNo

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.

What this section covers

This section contains three in-depth guides for the most common service worker tasks: