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.

Published October 2, 2026 Updated October 2, 2026 8 min read
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.

Where each background capability lives in MV3Worker-native APIs available directly in the service worker, extension APIs that replace web storage, and DOM-dependent work moved to an offscreen document.Worker-nativefetch, OffscreenCanvas, IndexedDB, cryptouse directlyExtension APIschrome.storage, chrome.alarmsreplace web storage, timersWorker-safe librariesHTML tokenisers, regex parsersno DOM neededOffscreen documentDOMParser, Audio, clipboardcreated on demand
Try the top band first; reach for an offscreen document only for genuinely DOM-bound 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.

One-time localStorage migration after the updateOn update the worker creates an offscreen document, asks it for every localStorage entry, writes them to chrome.storage.local with a migrated flag, and closes the document.Service workerOffscreen docstorage.localcreateDocument(LOCAL_STORAGE)dump-localstorage{ theme, apiKey, … }set({…, legacyMigrated:true})closeDocument()
The offscreen page shares the extension origin, so it can read what the old background page wrote.

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.

MV2 background APIs and their MV3 homesBackground-page APIs from MV2 mapped to their MV3 replacement and whether an offscreen document is required.MV2 APIMV3 replacementOffscreen neededXMLHttpRequestfetchNolocalStoragechrome.storageOnly to migratecanvas / ImageOffscreenCanvasNoDOMParserLibrary or offscreenSometimesAudioOffscreen documentYesdocument.execCommand('copy')Offscreen clipboardYeswindow.matchMediaOffscreen or pageYes
Only a minority of replacements need an offscreen document.

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 checks window.document. Fix the dependency or move the code.
  • Creating an offscreen document per task. Only one may exist; a second createDocument throws. Check with chrome.runtime.getContexts or track it, and reuse one document for several reasons.
  • Keeping synchronous storage semantics. Code that read localStorage inline becomes subtly wrong when replaced with an un-awaited chrome.storage.get. Make the callers async.
  • Forgetting the MV2 data. localStorage from 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.offscreen from 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 offscreen API; 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

  1. Grep the worker bundle for document, window, XMLHttpRequest, localStorage and DOMParser: expect no hits outside guarded, feature-detected branches.
  2. Load the extension, open the worker’s DevTools, and confirm it starts without a ReferenceError.
  3. Update a profile from the MV2 build and confirm settings from localStorage appear in chrome.storage.local with legacyMigrated: true.
  4. 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.

Other MV3 Architecture & Extension Lifecycle Resources