Offscreen Alternatives in Firefox and Safari

Ship features built on chrome.offscreen to Firefox and Safari — using Firefox's DOM-capable background page, Safari's constraints, and one adapter that picks the right host per engine.

Published September 18, 2026 Updated September 18, 2026 7 min read
Table of Contents

chrome.offscreen is a Chrome answer to a Chrome problem: MV3 moved Chrome’s background context to a service worker with no DOM, and the offscreen document gives the DOM back. Firefox never had that problem — its MV3 background is an event page with a full document — and Safari has the problem without the answer. A cross-browser extension therefore needs one abstraction over three quite different realities. This guide is part of offscreen documents and DOM access.

Three engines, three places the DOM lives

Where DOM work can run in each engineBackground context type, whether it has a DOM, whether an offscreen API exists, and the practical host for DOM-dependent features in Chrome, Firefox and Safari.PropertyChrome / EdgeFirefoxSafariBackground contextService workerEvent pageService workerdocument in backgroundNoYesNoOffscreen APIchrome.offscreenNot neededNot availableClipboard from backgroundVia offscreenDirectlyNot reliablyDOMParser from backgroundVia offscreenDirectlyNot availableAudio playbackVia offscreenDirectlyVisible page only
Firefox needs less machinery than Chrome; Safari needs a different design rather than a substitute API.

Step-by-step

1. Hide the host behind one function

The feature code should ask for “a DOM host” and never know which kind it got.

 1// dom-host.js
 2const api = globalThis.browser ?? globalThis.chrome;
 3
 4export async function runInDom(message) {
 5  if (typeof document !== "undefined") {
 6    return handleLocally(message);                       // Firefox event page
 7  }
 8  if (api.offscreen) {
 9    await ensureOffscreen();
10    return api.runtime.sendMessage({ target: "offscreen", ...message });   // Chrome
11  }
12  throw new DomUnavailable(message.type);                // Safari — caller decides
13}

Execution context: the background context of whichever engine is running. The typeof document check is the cleanest discriminator: it is true on Firefox’s event page and false in every service worker, so it needs no browser sniffing — in line with feature detection instead of browser sniffing.

2. Share the handler between the two DOM hosts

On Chrome the handler runs inside the offscreen document; on Firefox it runs inside the background page. Write it once as a module both import.

 1// dom-handlers.js — pure DOM code, no chrome.* calls
 2export function handleLocally({ type, payload }) {
 3  switch (type) {
 4    case "parse:html": {
 5      const doc = new DOMParser().parseFromString(payload, "text/html");
 6      return { title: doc.title, links: [...doc.querySelectorAll("a[href]")].map((a) => a.href) };
 7    }
 8    case "clipboard:write": {
 9      const ta = document.createElement("textarea");
10      ta.value = payload; document.body.append(ta); ta.select();
11      const ok = document.execCommand("copy"); ta.remove();
12      return { ok };
13    }
14  }
15  throw new Error(`unknown DOM task ${type}`);
16}

Execution context: either the Chrome offscreen document or the Firefox background page — both are real documents with the extension’s origin. Keeping chrome.* calls out of this module is what lets it run unchanged in both.

3. Build different manifests for each target

Firefox rejects the offscreen permission it does not know, and Chrome ignores background.scripts. Emit the right shape per target.

 1// build/manifest.js
 2export function manifestFor(target) {
 3  const base = { manifest_version: 3, name: "Reader", version: "2.4.0", permissions: ["storage"] };
 4  if (target === "firefox") {
 5    return { ...base, background: { scripts: ["background.js"], type: "module" } };
 6  }
 7  return {
 8    ...base,
 9    permissions: [...base.permissions, "offscreen"],
10    background: { service_worker: "background.js", type: "module" },
11  };
12}

Execution context: the build, in Node. The same background.js works as both a Firefox event-page script and a Chrome service worker because the DOM host adapter decides at runtime — the per-target generation is expanded in generating a manifest per browser target.

4. Design Safari around the gap, not through it

Safari has no hidden DOM host. The options are to move the work somewhere with a DOM that already exists, or to not offer the feature.

  • Parsing can move into a content script, which runs in a document — acceptable when the HTML came from the page anyway.
  • Clipboard writes can be kept to actions started in the popup, which is focused.
  • Audio can play from the popup while it is open, and nowhere else.
  • Anything long-running — capture, continuous playback — should simply be absent on Safari.
1try {
2  return await runInDom({ type: "clipboard:write", payload: text });
3} catch (err) {
4  if (err instanceof DomUnavailable) return { ok: false, reason: "open the popup to copy" };
5  throw err;
6}

Execution context: the Safari background context. Returning an explanation the UI can show is better than a context-menu item that silently does nothing; hiding the item entirely on Safari is better still.

5. Keep the capability visible to the UI

1export const domCapabilities = () => ({
2  backgroundDom: typeof document !== "undefined" || !!(globalThis.chrome?.offscreen),
3  popupOnlyDom: typeof document === "undefined" && !globalThis.chrome?.offscreen,
4});

Execution context: the background context, published to storage.session on startup like the rest of the matrix. The popup reads it and hides worker-initiated copy and playback controls where popupOnlyDom is true.

One call, three hostsrunInDom checks for a local document first, then for the offscreen API, and otherwise throws a typed error that the Safari UI turns into a hidden or explained control.runInDom(message)feature codedocument defined?Firefox event pagehandleLocally()no messagingotherwisechrome.offscreen?Chrome / Edgeoffscreen documentsame handler moduleDomUnavailableSafari — UI adapts
The order of the checks is the design — Firefox's direct path is tried first because it is the cheapest.

Firefox’s event page is not a free lunch

It is tempting to treat Firefox’s DOM-capable background as “MV2 again” and to keep long-lived state in it. That works until Firefox suspends the event page, which it does after a period of inactivity — less aggressively than Chrome evicts its worker, but reliably. Timers are lost, open AudioContexts are torn down, and anything held only in a JavaScript variable is gone.

The rules from the Chrome side therefore still apply on Firefox: register listeners at the top level, persist state to storage, schedule with alarms. The DOM is available; persistence is not.

1// Firefox background page — the same discipline as a Chrome worker
2browser.runtime.onStartup.addListener(restoreFromStorage);
3browser.alarms.onAlarm.addListener(onTick);            // not setInterval

Execution context: the Firefox MV3 background page. Writing it this way also means the same file runs correctly as a Chrome service worker, which is the actual goal — the lifecycle model is described in persistent vs non-persistent service workers explained.

Firefox event page suspension compared with Chrome worker evictionTwo idle periods: the Chrome service worker is evicted after about thirty seconds, while Firefox's event page survives longer but is still suspended.last event+2 minBoth alivehandling eventsChrome worker evicted~30 s idleFirefox page still alivelonger graceFirefox page suspendedstate lostChrome timers goneFirefox timers gone
Firefox's longer grace period hides the bug in development; it does not remove it.

Cross-browser variation

  • Chrome / Edge: the offscreen document is the only hidden DOM host. One per extension, created on demand, closed when idle.
  • Firefox: the background event page has document, DOMParser, Audio and clipboard access via execCommand. No offscreen API exists or is needed, and declaring the permission produces a manifest warning.
  • Safari: no hidden DOM host at all. DOM-dependent background features must move to a visible surface or be omitted, and the UI should say which.
  • All three: the popup is a DOM host everywhere, but only while it is open — it is a valid host for user-initiated, sub-second work and for nothing else.

Verification

  1. In each browser’s background console, confirm which path the adapter takes:
1typeof document !== "undefined" ? "local" : (globalThis.chrome?.offscreen ? "offscreen" : "none");
2// Firefox → "local", Chrome → "offscreen", Safari → "none"

Execution context: the background console of the engine under test. Any other answer means the build shipped the wrong manifest for that target.

  1. Run the same parse task on Chrome and Firefox and confirm identical results.
  2. On Safari, confirm the worker-initiated controls are hidden and the popup-initiated ones work.
  3. On Firefox, leave the extension idle for several minutes and confirm a scheduled task still fires — proof nothing relied on the event page staying resident.

FAQ

Can I use chrome.offscreen in Firefox with a polyfill?

No polyfill can create a hidden document where the platform does not offer one — and in Firefox you do not need one, because the background already has a DOM. Detect and use it directly.

Is Firefox’s background page going to become a service worker?

Firefox has signalled support for service-worker backgrounds, but event pages remain the default. The adapter above handles either, because it keys on document rather than on the browser.

What is the least bad Safari option for parsing HTML?

Parse in a content script when the HTML came from a page, since the content script has a document. For HTML fetched by the extension itself, a small tolerant string-based extractor is often enough for titles and links.

Other MV3 Architecture & Extension Lifecycle Resources