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.

What resets the idle timer and what does notThe idle countdown restarts on incoming events and while a port is open, but a running loop inside a handler does not extend it.handler returnsevictionIdle timer running~30 sNew event…timer rest…Idle timer running~30 s againEvictedno event in…an alarm, message or port countsa busy for-loop does not
Only events and open ports reset the timer; CPU work on its own is invisible to the eviction logic.

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.

Hydrate-per-event instead of hydrate-onceA cached module-scope value is only valid within one wake; each handler reads through a hydrate function that falls back to storage.Handler startsany eventCached value?module scope, this wake onlystorage.local.getauthoritativemutate, then write back before the handler returnsApply the changein memorystorage.local.setawait itReturnsafe to be evicted now
The cache is a within-wake optimisation, never a source of truth — write it off on every cold start.
 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.

7. Thinking in events, not in a running program

The single largest adjustment in moving to Manifest V3 is conceptual. A Manifest V2 background page was a program: it started with the browser, held state in variables, ran timers and kept connections open for as long as the browser ran. An MV3 service worker is a set of event handlers the browser runs on demand. Between events it may not exist at all, and nothing about the previous run is guaranteed to survive into the next.

Code written with the program mental model tends to fail in characteristic ways: a cache in a module variable that is empty after the next wake, a setInterval that stops after thirty seconds, a WebSocket that silently disappears, an initialisation routine that has not finished when the first message arrives. Code written with the event model avoids these by construction. Each handler reads the state it needs from storage, does its work, writes results back, and returns — assuming nothing was left behind by any earlier handler and leaving nothing behind that the next one depends on.

The practical test is to ask of every piece of state: if the worker were stopped right now and restarted for the next event, would this still be correct? Anything that fails the test belongs in chrome.storage, with storage.session for state that should not outlive the browser session.

8. Where the worker spends its life

In a typical day a worker is started and stopped dozens or hundreds of times. It wakes for an alarm, handles it in a few milliseconds, idles, and is evicted; it wakes again for a message from the popup, answers, and is evicted again. Only a small fraction of its lifetime is spent doing work, and the largest single cost is usually the cold start itself: evaluating the module graph and running top-level code before the first handler can run.

That shapes what is worth optimising. A smaller module graph and less top-level work shorten every cold start, and that improvement is felt on every event after an idle period. Heavy libraries used by only one handler should be imported inside that handler, not at the top level. And work that can be deferred to the next time the user opens the popup, rather than done on a timer in the background, avoids waking the worker at all. The measurements behind these choices are in reducing service worker cold start latency.

9. The keep-alive temptation

Faced with a worker that keeps disappearing, many developers reach for ways to keep it alive: a port held open, a periodic ping, an alarm every thirty seconds. Some of these work for a while. All of them fight the platform, cost the user battery and memory, and are liable to stop working as browsers tighten their rules — and a reviewer who notices an extension deliberately defeating worker termination may ask why.

The better answer is almost always to change the work rather than the worker’s lifetime. Long jobs become chains of short slices driven by alarms, each resuming from a cursor in storage, as described in chaining alarms for long-running jobs. Work that needs a DOM moves to an offscreen document with its own lifetime. Streaming connections move to a surface the user has open, such as a side panel, which legitimately keeps its connection while visible. What remains for the worker is short, event-driven work — which is exactly what it is designed for.

10. Testing the lifecycle, not just the logic

Because the worker’s behaviour depends so heavily on when it starts and stops, tests that only exercise a warm worker miss the bugs that matter. Add tests that stop the worker and then trigger each kind of event — a message, an alarm, a context-menu click — to confirm listeners are registered in time. Add a test that loads the previous release, populates storage, and then loads the new one, to confirm onInstalled rebuilds alarms, menus and registrations. And whenever a bug report says a feature “sometimes” does nothing, suspect the lifecycle first; intermittent failures in an extension are far more often a cold-start race than a logic error. The techniques are in driving service worker state from a test.

11. A checklist for every new handler

Before any new event handler ships, it is worth running through five questions. Is the listener registered synchronously at the top level of the worker, before any await? Does the handler read everything it needs from storage rather than from module variables set by some earlier event? Does it return a promise, or true for message replies, so the worker stays alive until the work finishes? Is its work short enough to finish comfortably within the worker’s lifetime, or split into resumable slices if not? And does it handle the case where the event arrives on a freshly updated extension, with data written by an older version? A handler that passes all five will behave the same on a developer’s machine with DevTools open and on a user’s laptop after a week of idle time.

Keep the checklist in the repository’s contributing guide and in the pull-request template for changes that touch the worker. It is short enough to read in a minute, and the bugs it prevents are among the hardest to diagnose after release, because they only appear on users’ machines under conditions a developer’s open inspector hides.

When a handler does fail one of the questions, fix the structure rather than adding a workaround: move the registration, persist the state, split the work. Workarounds that keep the worker alive longer tend to hide the underlying problem until a browser update removes the grace period they relied on.

MV3 constraints to design around

  • 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:

Further guides in this topic

The guides below go deeper into specific service worker fundamentals problems that the sections above only touch on — each one starts from a concrete symptom and ends with a way to verify the fix.

  • Registering Listeners at the Top Level — Why every MV3 service worker event listener must be added synchronously on the first pass of the script — what goes wrong otherwise, and patterns that keep registration early as code grows.
  • Using ES Modules in an MV3 Service Worker — Declare a module service worker in Manifest V3 — type: module, static imports only, no importScripts, how bundlers emit it, and what changes for Firefox and Safari.