Background Script Support Across Browsers

A reference table for MV3 background contexts: service worker versus event page, termination policy, available globals, and the code shape that runs correctly in Chrome, Firefox and Safari.

Published July 25, 2026 Updated July 25, 2026 8 min read
Table of Contents

The background context is where the three engines diverge most, and the divergence is asymmetric: code written for Chrome runs in Firefox, while code written in Firefox frequently fails in Chrome. That is because Firefox’s event page keeps state and globals that Chrome’s service worker does not, so a missing persistence bug is invisible in one engine and fatal in the other. This page is part of Service Worker Fundamentals and is the reference table for what each engine actually provides.

Root cause: two different background models under one manifest key

Manifest V3 specifies a background key, not an execution model. Chrome and Safari implement it as a service worker — no DOM, no window, evicted on idle. Firefox accepts both a service worker and an event page, and its event page is a document with DOM access that terminates far less eagerly. Both are conforming; they are not interchangeable.

Background context capabilities by engineContext type, termination, DOM availability, storage of globals and the manifest declaration accepted by each browser.PropertyChrome 120+Firefox 121+Safari 17+Context typeService workerEvent page or workerService workerIdle termination~30 sNon-deterministic~30 sDOM availableNoYes in an event pageNoGlobals surviveNoOftenNoHard invocation cap5 minutesNone documentedSimilar to Chrometype: moduleSupportedSupportedSupported
Rows three and four are where portability breaks — everything else is close enough to ignore.

What each engine accepts in the manifest

Declaring the service worker form everywhere is the simplest portable choice, at the cost of requiring a recent Firefox. Declaring both keys is possible but each engine ignores the one it does not use, so the code must still satisfy the stricter model.

 1// The portable declaration: one worker, one code path.
 2{
 3  "background": {
 4    "service_worker": "background.js",
 5    "type": "module"
 6  },
 7  // Firefox needs an id to sign against; Chrome rejects this key, so it is merged per target.
 8  "browser_specific_settings": {
 9    "gecko": { "id": "example@example.com", "strict_min_version": "121.0" }
10  }
11}

Execution context: Parsed at install time by each engine. Firefox has supported background.service_worker since release 121; older releases require the scripts array form, so strict_min_version is what stops the extension installing where the declaration will not work. If you must support older Firefox, ship the scripts form in the Firefox target and keep the same code, since a script that satisfies the worker constraints also runs fine in an event page.

The globals that differ

Most chrome.* APIs are present everywhere. What differs is the web platform surface underneath, and that is where a Firefox-developed extension picks up dependencies Chrome cannot satisfy.

What the background context can reach, by engineExtension APIs are available everywhere; fetch and crypto are available everywhere; DOM APIs and window are available only in Firefox's event page.window, document, DOMParser, AudioFirefox event page onlyneeds an offscreen document in ChromelocalStorageFirefox event page onlyuse chrome.storage insteadfetch, crypto, TextDecoder, URLevery enginesafe to usechrome.* extension APIsevery enginethe intended surface
Write against the bottom two layers only, and the same file runs in all three engines.

The code shape that works everywhere

Three rules cover almost all of it: register listeners synchronously, keep no state in module scope, and use only the globals in the safe layers above.

 1// background.js — portable across all three engines
 2import { handleMessage } from "./handlers.js";
 3
 4// 1. Every listener is registered during synchronous evaluation.
 5chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
 6  (async () => sendResponse(await handleMessage(message, sender)))();
 7  return true;
 8});
 9
10chrome.alarms.onAlarm.addListener((alarm) => { void onAlarm(alarm); });
11
12// 2. Module scope holds only constants and pure functions — never state.
13const POLL_ALARM = "poll";
14
15// 3. Anything durable is read and written through chrome.storage, per event.
16async function onAlarm(alarm) {
17  if (alarm.name !== POLL_ALARM) return;
18  const { cursor = 0 } = await chrome.storage.local.get("cursor");
19  const next = await pollSince(cursor);
20  await chrome.storage.local.set({ cursor: next });
21}

Execution context: Runs identically as a Chrome service worker, a Safari service worker and a Firefox event page. The synchronous registration is required by Chrome and harmless in Firefox; the storage round trip is required by Chrome and merely slightly redundant in Firefox. Writing to the stricter model costs nothing on the more forgiving one, which is why this direction of portability is the only one that works.

The same extension's background lifetime in two enginesChrome evicts the worker roughly thirty seconds after the last event; Firefox's event page commonly persists far longer, hiding state-loss bugs.handler finishesmuch laterChrome: idle~30 sChrome: evictedglobals goneFirefox: still residentglobals intactstate-loss bugs surface here in Chromeand are still invisible here in Firefox
A bug that needs an eviction to appear may never appear at all during Firefox development.

Testing the strict model in a forgiving engine

Because Firefox hides the failures Chrome exposes, a portability suite has to force the strict behaviour rather than wait for it. Two habits do most of the work: terminate the background context between assertions, and assert that module scope is empty after it restarts.

 1// tests/portability.spec.js — Playwright against the Chrome build
 2test("state survives a worker restart", async ({ context }) => {
 3  const [worker] = context.serviceWorkers();
 4
 5  await worker.evaluate(async () => {
 6    await chrome.storage.local.set({ cursor: 42 });
 7    globalThis.__cachedCursor = 42;         // deliberately the wrong pattern
 8  });
 9
10  // Force the eviction that Firefox may never perform on its own.
11  await worker.evaluate(() => globalThis.registration.unregister());
12  await context.waitForEvent("serviceworker");
13
14  const [restarted] = context.serviceWorkers();
15  const result = await restarted.evaluate(async () => ({
16    fromStorage: (await chrome.storage.local.get("cursor")).cursor,
17    fromMemory: globalThis.__cachedCursor ?? null,
18  }));
19
20  expect(result.fromStorage).toBe(42);
21  expect(result.fromMemory).toBeNull();      // proves the cache did not survive
22});

Execution context: Playwright test against the Chrome build, driving the worker through a persistent context. Asserting that the in-memory value is gone is the part that catches regressions: a test that only checks storage passes even when the code has quietly started depending on a cached global again. Running the same assertions against the Firefox build is worthwhile but weaker, because there the value may legitimately still be present.

Choosing a target to develop against

Develop against Chrome. It is the strictest of the three, its tooling is the best, and code that satisfies it runs on the other two — whereas the reverse costs a debugging session per divergence. Keep the Firefox and Safari builds in the release pipeline from the start so the divergences surface as small corrections rather than as a porting project, and put the capability checks in one module so adding an engine later touches one file.

Cross-browser variation

  • Chrome 120+: service worker only, roughly thirty seconds of idle before eviction, a five-minute cap on a single handler invocation, and no DOM. This is the strictest model and the one to develop against.
  • Firefox 121+: accepts both forms. The event page has DOM access and terminates unpredictably rather than on a fixed timer, so it is more forgiving in every respect except predictability.
  • Safari 17+: service worker with Chrome-like termination. No offscreen documents, so DOM work has no background home at all.
  • All engines: chrome.storage, chrome.alarms and declarative rules persist independently of the background context, which is what makes the portable shape possible.

What to do when an engine lacks the API entirely

Three background capabilities have no equivalent outside Chromium, and each needs a decision rather than a polyfill. DOM access is the largest: Chrome routes it through an offscreen document, Firefox has it inline in the event page, and Safari has no background DOM at all — so the portable answer is a capability check with three branches, one of which degrades the feature. Long-running work is the second: Chrome’s five-minute cap forces chunking, and chunking is correct everywhere else too, so writing to Chrome’s constraint costs nothing. The third is anything that assumes a persistent connection, such as a WebSocket held open across events; only Firefox’s event page can plausibly sustain one, and building on it produces an extension that works in one engine and silently drops messages in the other two.

The practical rule that follows is to write the background script as a pure function of its events: every handler reads what it needs, does bounded work, writes what it changed, and returns. A file written that way needs no engine checks at all, and the checks that remain are confined to the capability module described above.

Verification

  1. Develop against Chrome and stop the worker from the extensions page between every manual test. That single habit catches most portability bugs before they reach another engine.
  2. Grep the background bundle for document, window, localStorage and Audio. Any hit is a Firefox-only dependency.
  3. Load the Firefox build and confirm behaviour matches after you have forced the Chrome worker to terminate — the two should agree.
  4. Confirm every addListener call appears above the first await in the entry module.

FAQ

Should I target the event page or the service worker in Firefox?

The service worker, if you can require Firefox 121 or newer. One declaration and one code path is worth more than the extra reach, and code written for a worker runs correctly in an event page anyway.

Why does my extension work in Firefox but break in Chrome?

Almost always module-scope state or a late listener registration. Firefox’s event page keeps both alive long enough to hide the problem; Chrome’s eviction exposes it on the first idle period.

Can I use DOM APIs in the background at all?

Only in a Firefox event page. Chrome needs an offscreen document and Safari has no background DOM at all, so treat DOM access as a capability to detect rather than assume.

Is the five-minute cap real in Firefox and Safari?

Firefox documents no equivalent cap and Safari behaves much like Chrome. Design for the cap regardless — a handler that needs minutes should be chunked with alarms in every engine.

Other MV3 Architecture & Extension Lifecycle Resources