Replacing DOM and XHR in the Background Context
Move MV2 background-page code that used document, DOMParser, XMLHttpRequest, localStorage, Image or canvas into MV3: fetch, offscreen documents, OffscreenCanvas, chrome.storage and worker-safe libraries.
Table of Contents
The migrated service worker crashes on its first line with “ReferenceError: document is not defined”, or “window is not defined”, or “XMLHttpRequest is not defined”. An MV2 background page was a real HTML page: it had a DOM, localStorage, Image, Audio, a canvas, XMLHttpRequest and every web API a tab has. An MV3 service worker is a worker: it has fetch, caches, IndexedDB, OffscreenCanvas, crypto and a handful of other worker APIs, and nothing that touches a document. Each DOM-dependent piece of the old background needs a new home, and the right home depends on what the code was doing. This guide belongs to Manifest V2 to V3 migration.
What the worker has and lacks
The split is principled: workers have APIs that do not need a rendering context, and lack everything that does. fetch, Request, Response, URL, TextEncoder, streams, crypto.subtle, IndexedDB, caches, OffscreenCanvas, createImageBitmap, WebAssembly and structuredClone are all present. document, window, DOMParser, XMLHttpRequest, localStorage, sessionStorage, Image, Audio, navigator.clipboard, alert and matchMedia are absent. For the absent ones, MV3 offers three kinds of replacement: a worker-native equivalent (fetch for XHR), an extension API (chrome.storage for localStorage), or a borrowed document — an offscreen document, which is a hidden extension page created on demand for DOM work.
Step-by-step: replace each dependency
1. XMLHttpRequest → fetch
1// MV2
2const xhr = new XMLHttpRequest();
3xhr.open("GET", url);
4xhr.responseType = "json";
5xhr.onload = () => done(xhr.response);
6xhr.send();
7
8// MV3
9const res = await fetch(url, { credentials: "omit" });
10if (!res.ok) throw new Error(`HTTP ${res.status}`);
11done(await res.json());
Execution context: the service worker. Most HTTP libraries can be switched to a fetch adapter; Axios 1.7+ supports adapter: "fetch", and older versions need replacing. Upload progress, which XHR provided via xhr.upload.onprogress, has no direct fetch equivalent; if you need it, stream the body yourself or perform the upload from an extension page.
2. localStorage → chrome.storage, with a one-time migration
1// MV3 — reading old values requires a document; do it once, from an offscreen page
2async function migrateLegacyLocalStorage() {
3 const { legacyMigrated } = await chrome.storage.local.get("legacyMigrated");
4 if (legacyMigrated) return;
5 await chrome.offscreen.createDocument({
6 url: "legacy-reader.html",
7 reasons: ["LOCAL_STORAGE"],
8 justification: "Migrate settings saved by the previous version",
9 });
10 const data = await chrome.runtime.sendMessage({ target: "offscreen", type: "dump-localstorage" });
11 await chrome.storage.local.set({ ...data, legacyMigrated: true });
12 await chrome.offscreen.closeDocument();
13}
1// legacy-reader.js (offscreen document)
2chrome.runtime.onMessage.addListener((msg, _s, reply) => {
3 if (msg.target !== "offscreen" || msg.type !== "dump-localstorage") return;
4 reply(Object.fromEntries(Object.entries(localStorage)));
5});
Execution context: the service worker creates the offscreen document, which shares the extension origin and therefore sees the localStorage the MV2 background page wrote. LOCAL_STORAGE is a dedicated offscreen reason for exactly this. After the migration, all reads and writes go through chrome.storage, which is asynchronous — code that read localStorage.foo synchronously must become await-based.
3. DOMParser → a worker-safe parser or an offscreen document
1// Simple extraction: a regex or tokenizer is often enough
2function extractTitle(html) {
3 const m = html.match(/<title[^>]*>([^<]*)<\/title>/i);
4 return m ? m[1].trim() : null;
5}
6
7// Real parsing: a DOM-free library bundled into the worker
8import { parse } from "node-html-parser";
9const root = parse(html);
10const links = root.querySelectorAll("a[href]").map((a) => a.getAttribute("href"));
Execution context: the service worker. DOM-free HTML parsers implement enough of querySelector for most scraping and run comfortably in a worker. When you need full browser-grade parsing — resolving relative URLs against a base, running CSS selectors against computed structure, sanitising with the browser’s own parser — use an offscreen document with the DOM_PARSER reason, as described in parsing HTML without innerHTML in MV3.
4. Canvas and Image → OffscreenCanvas and createImageBitmap
1// MV2: new Image() + <canvas> to resize an icon
2// MV3:
3export async function resizeToIcon(url, size = 32) {
4 const blob = await (await fetch(url)).blob();
5 const bitmap = await createImageBitmap(blob);
6 const canvas = new OffscreenCanvas(size, size);
7 const ctx = canvas.getContext("2d");
8 ctx.drawImage(bitmap, 0, 0, size, size);
9 return ctx.getImageData(0, 0, size, size); // ready for chrome.action.setIcon({ imageData })
10}
Execution context: the service worker. OffscreenCanvas and createImageBitmap are worker-native, so image work never needs an offscreen document. SVG images are an exception — createImageBitmap cannot decode SVG in a worker in every engine — so rasterise SVG icons at build time. Dynamic icons are covered in drawing dynamic action icons with OffscreenCanvas.
5. Audio, clipboard and matchMedia → offscreen document
1await chrome.offscreen.createDocument({
2 url: "offscreen.html",
3 reasons: ["AUDIO_PLAYBACK"],
4 justification: "Play the timer-finished chime",
5});
6await chrome.runtime.sendMessage({ target: "offscreen", type: "play", src: "chime.mp3" });
Execution context: the service worker creating a document; the offscreen page does the playing. Each capability has its own reason — AUDIO_PLAYBACK, CLIPBOARD, MATCH_MEDIA, GEOLOCATION, and others — and Chrome may close a document whose reason no longer applies, such as an audio document that has gone silent for thirty seconds. Only one offscreen document may exist at a time, so a shared document handling several reasons is common. See choosing the right offscreen reason.
6. Remove window-isms from shared code
1// Shared module that must load in the worker and in pages
2const g = globalThis; // not window
3const now = () => g.performance.now();
4const isWorker = typeof g.document === "undefined";
Execution context: any context. Libraries and utility modules often reference window incidentally — window.setTimeout, window.location, window.addEventListener("online"). globalThis works everywhere. A single window reference at module top level crashes the worker before any listener registers, which looks like the worker “never starts”. Bundle with a target of webworker or es2022 and run the worker bundle in a Node test to catch these early.
Common mistakes
- Shimming
window = self. It silences the reference error and breaks the first library that checkswindow.document. Fix the dependency or move the code. - Creating an offscreen document per task. Only one may exist; a second
createDocumentthrows. Check withchrome.runtime.getContextsor track it, and reuse one document for several reasons. - Keeping synchronous storage semantics. Code that read
localStorageinline becomes subtly wrong when replaced with an un-awaitedchrome.storage.get. Make the callers async. - Forgetting the MV2 data.
localStoragefrom the old background page is not migrated automatically; without step 2, users lose their settings on update. - Using an offscreen document as a persistent background. Chrome closes documents whose reason has lapsed. It is a tool for DOM tasks, not a way back to MV2.
Cross-browser variation
- Chrome / Edge: service worker background;
chrome.offscreenfrom Chrome 109 with a growing list of reasons. - Firefox: MV3 background is an event page with a full DOM by default, so DOM code keeps working there — and masks the problem until you test in Chrome. Firefox has no
offscreenAPI; feature-detect it. - Safari: supports a service worker or a non-persistent background page in MV3. With a page, the DOM is available; with a worker, it is not and there is no offscreen API, so prefer worker-native replacements.
Verification
- Grep the worker bundle for
document,window,XMLHttpRequest,localStorageandDOMParser: expect no hits outside guarded, feature-detected branches. - Load the extension, open the worker’s DevTools, and confirm it starts without a
ReferenceError. - Update a profile from the MV2 build and confirm settings from
localStorageappear inchrome.storage.localwithlegacyMigrated: true. - Exercise every DOM-dependent feature and confirm
chrome.runtime.getContexts({ contextTypes: ["OFFSCREEN_DOCUMENT"] })shows at most one document, closed when idle.
FAQ
Can I use jsdom in the service worker?
It depends on Node-specific modules and is far too large for an extension worker. Use a small DOM-free parser or an offscreen document.
Is IndexedDB available in the worker?
Yes, and it is the right store for large structured data. It is shared with extension pages on the same origin.
Does Firefox need any of this?
Its event page has a DOM, so not strictly — but a single code path that follows these rules works in all three engines.
Related
- Creating and closing offscreen documents — offscreen lifecycle in detail.
- Using ES modules in an MV3 service worker — bundling worker-safe code.
- MV2 to MV3 migration checklist — where this fits in the plan.
- Manifest V2 to V3 migration — the parent topic.