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.
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
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.
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.
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,Audioand clipboard access viaexecCommand. 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
- 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.
- Run the same parse task on Chrome and Firefox and confirm identical results.
- On Safari, confirm the worker-initiated controls are hidden and the popup-initiated ones work.
- 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.
Related
- Creating and closing offscreen documents — the Chrome side of the adapter.
- Background script support across browsers — why the background contexts differ.
- Handling Safari Web Extension conversion gaps — the broader Safari picture.
- Offscreen documents and DOM access — the parent guide.