Skip to content

Fixing "document is not defined" in a Chrome Extension Service Worker

6 min readModerok team

A Chrome MV3 service worker has no DOM, so "document is not defined" is expected: here is how to find the line that threw and the three ways to fix it.

If your extension logs Uncaught ReferenceError: document is not defined, nothing is misconfigured. In Manifest V3 your background code runs in a service worker, and a service worker has no DOM: there is no document, no window, and no localStorage in that scope. The error is the runtime telling you a global you assumed exists simply is not there. There are three real fixes, and picking the right one comes down to a single question: whose DOM do you actually need? Your own, a web page's, or none at all.

Why "document is not defined" happens in an MV3 service worker

A service worker is a worker, not a page. Its global object is self, a ServiceWorkerGlobalScope, which inherits from WorkerGlobalScope, and neither interface ever had document or window on it. So document.createElement(...) in a background script is not a permissions problem or a manifest problem. The identifier is unbound, and JavaScript throws a plain ReferenceError.

Chrome's own migration guide states the constraint directly: because service workers "can't access the DOM or the window interface," you need to move such calls to a different API or into an offscreen document. The same page notes two neighbouring surprises that produce the same class of crash: XMLHttpRequest() "can't be called from a service worker, extension or otherwise," and the web platform's Storage interface, reached through window.localStorage, "cannot be used in a service worker."

Pasted into the service worker console:

typeof self;         // "object"   <- the worker global
typeof window;       // "undefined"
typeof document;     // "undefined"
typeof localStorage; // "undefined"
typeof fetch;        // "function" <- this one is fine

It is usually a dependency, not your own code

Few people write document.querySelector in a background script on purpose. The call almost always arrives inside a bundled library that was written for web pages: an analytics snippet, a UI helper, a polyfill that installs itself on window, a markdown or HTML sanitiser that reaches for DOMParser.

The worst version is a DOM reference at module scope, evaluated on import:

// Inside some dependency, at the top level of the module.
const container = document.createElement("div");

That throws while the worker is still evaluating its top-level script, which means your own code below it never runs. Every listener you meant to register is skipped, including chrome.runtime.onInstalled, so the install event fires into a worker that crashed before it could subscribe, silently costing you your install tracking. This is the same failure mode behind why Google Analytics does not work in a Chrome extension: the library was built for a page, and there is no page.

Find the line that threw

Open chrome://extensions, turn on Developer mode, and click the service worker link on the extension's card. That opens DevTools attached to the worker, where the stack trace lives. The card's Errors button also collects errors that happened when you were not looking, which matters because a worker can start and crash long after you loaded the extension.

If your background bundle is minified, the trace will point at a single unreadable line. Build a development bundle with source maps before you go hunting. A crude but effective pre-flight check is to grep the built background file for DOM globals:

grep -n "document\.\|window\.\|localStorage" dist/background.js

Hits inside vendored code tell you which dependency to replace.

Fix 1: swap the API for a worker-safe equivalent

Often you do not need a DOM at all, just an API that happens to live on one. Common substitutions:

  • localStorage becomes chrome.storage.local, which is asynchronous and has its own quota.
  • XMLHttpRequest becomes fetch(), which works in the worker unchanged.
  • document.createElement("a") used to parse a URL becomes new URL(href).
  • document.createElement("canvas") for resizing or re-encoding an image becomes OffscreenCanvas, which is available in workers.

There is no substitute for DOMParser in a service worker. That gap is real enough that Chrome's offscreen API ships a dedicated DOM_PARSER reason for it, which brings us to the other two fixes.

Fix 2: move the work to a content script

If the DOM you need belongs to a web page the user is looking at, the service worker was never the right home for that code. Put it in a content script, which runs in the page and has the page's document, then talk to the worker with chrome.runtime.sendMessage().

The tradeoff is that content scripts inherit the host page's environment, including its Content Security Policy. Chrome describes this as leaving the extension "at the mercy of different content security policies on a page-to-page basis."

Fix 3: create an offscreen document

If you need a DOM that belongs to your extension rather than to a page, that is what the offscreen API is for. It landed in Chrome 109 and gives you a hidden, extension-owned HTML document that the service worker can create on demand. Declare the permission:

{
  "permissions": ["offscreen"]
}

Then create the document from the worker. createDocument() requires a url pointing at a static HTML file inside your package, one or more reasons, and a developer-written justification. An installed extension can only have one offscreen document open at a time (in split mode with an active incognito profile, the normal and incognito profiles get one each), so the documented pattern checks for an existing one first:

let creating; // A global promise to avoid concurrency issues.

async function ensureOffscreen(path) {
  const offscreenUrl = chrome.runtime.getURL(path);
  const contexts = await chrome.runtime.getContexts({
    contextTypes: ["OFFSCREEN_DOCUMENT"],
    documentUrls: [offscreenUrl],
  });

  if (contexts.length > 0) return;

  if (creating) {
    await creating;
  } else {
    creating = chrome.offscreen.createDocument({
      url: path,
      reasons: ["DOM_PARSER"],
      justification: "Parse HTML returned by our API into a document.",
    });
    await creating;
    creating = null;
  }
}

chrome.runtime.getContexts() was added in Chrome 116; on older versions the documented fallback is clients.matchAll(). There is a fixed list of valid reasons, which grows over time as new use cases are accepted; CLIPBOARD, AUDIO_PLAYBACK, DOM_PARSER, DOM_SCRAPING, BLOBS, LOCAL_STORAGE, IFRAME_SCRIPTING, USER_MEDIA, DISPLAY_MEDIA, and WEB_RTC are among them, and the current set is in the API reference. The reason you pick affects the document's lifespan. AUDIO_PLAYBACK closes the document after 30 seconds without audio playing; every other reason sets no lifetime limit, so you are responsible for calling chrome.offscreen.closeDocument() when the work is done.

Two constraints trip people up. First, chrome.runtime is the only extensions API available inside an offscreen document, so all coordination happens over message passing and you cannot, say, call chrome.storage from there. Second, an offscreen document is not a background page in disguise. Chrome is explicit that it "should not be the place to store primary extension logic because it has limited API access." Use it for the DOM-shaped task, send the result back, close it.

Why the same code works in Firefox

If a colleague insists the code is fine because it runs in Firefox, they are not wrong. Firefox does not support the background.service_worker manifest key at all; it runs background.scripts as documents, which means document exists there and DOM calls quietly succeed. Chrome, meanwhile, only ever gives you a service worker: from Chrome 121 it ignores background.scripts in a Manifest V3 extension, and before 121 it refused to load the extension at all if that key was present. One background file, two environments, one of which has a DOM. See Firefox MV3 event pages versus Chrome service workers for the manifest that satisfies both.

Keeping DOM assumptions out of your background bundle

The crash is the good outcome. The bad outcome is a library that wraps its DOM access in typeof window !== "undefined" and silently does nothing when the check fails, so your events never send and nothing appears in any log. When you evaluate anything that runs in the worker, grep its source for DOM globals before you trust it.

That constraint shaped the Moderok SDK: there is no window, document, or localStorage anywhere in its source, it persists state through chrome.storage.local, it sends events with fetch(), and it has zero runtime dependencies, so no transitive package can drag a page assumption into your service worker. If you want extension analytics that was written for the worker instead of ported to it, take a look at Moderok.