Chrome Extension Offscreen Document: Using DOM APIs From an MV3 Service Worker
6 min readModerok team
How a chrome.offscreen document runs DOMParser, clipboard, or audio code for an MV3 service worker, plus the one-document limit and lifetime rules.
A Chrome MV3 extension service worker has no DOM. There is no document, no DOMParser, no localStorage, no <audio> element, so any code reaching for them throws. The supported workaround is an offscreen document: a hidden HTML page your service worker creates with chrome.offscreen.createDocument(), available since Chrome 109, that you talk to over chrome.runtime messaging. Declare the "offscreen" permission, bundle a static HTML file, create the document with a reason, then message the work in and the result back out.
Chrome's own reference puts it plainly: "Service workers don't have DOM access, and many websites have content security policies that limit the functionality of content scripts. The Offscreen API allows the extension to use DOM APIs in a hidden document without interrupting the user experience by opening new windows or tabs."
When you actually need an offscreen document
You do not get to invent a purpose. createDocument() takes a reasons array, and the values come from a fixed enum:
TESTING, AUDIO_PLAYBACK, IFRAME_SCRIPTING, DOM_SCRAPING, BLOBS, DOM_PARSER, USER_MEDIA, DISPLAY_MEDIA, WEB_RTC, CLIPBOARD, LOCAL_STORAGE, WORKERS, BATTERY_STATUS, MATCH_MEDIA, GEOLOCATION.
That list is the honest answer to "should I use one?" If your problem is parsing HTML you fetched, that is DOM_PARSER. Playing a notification sound is AUDIO_PLAYBACK. Writing to the system clipboard from a background context is CLIPBOARD. Reading data an old MV2 background page left in localStorage is LOCAL_STORAGE. If your reason is not on the list, you probably want a popup, an options page, or plain fetch in the worker instead.
createDocument() takes a single object, and its third property, justification, is a free-text string you write. Per the API typings it "explains, in more detail, the need for the background context. The user agent may use this in display to the user." It has no runtime effect, but write something truthful: a reviewer can read it.
The minimal setup
Three files. First the manifest:
{
"manifest_version": 3,
"name": "My extension",
"version": "1.0",
"background": { "service_worker": "background.js" },
"permissions": ["offscreen"]
}
Then the offscreen page itself. It must be a static HTML file bundled with the extension, so no remote URL and no data: URL:
<!-- offscreen.html -->
<!DOCTYPE html>
<script src="offscreen.js"></script>
The script inside does the DOM work and answers over chrome.runtime:
// offscreen.js
// Register the listener at the top level, so the document can receive
// messages as soon as createDocument() resolves.
chrome.runtime.onMessage.addListener((message) => {
if (message.target !== 'offscreen') return false;
if (message.type === 'parse-html') {
const doc = new DOMParser().parseFromString(message.data, 'text/html');
const title = doc.querySelector('h1')?.textContent ?? null;
chrome.runtime.sendMessage({ target: 'background', type: 'parse-result', data: title });
}
});
The target field is not ceremony. chrome.runtime.sendMessage() fans a message out to every listening extension context except the sender's own, so the service worker, the popup, and the offscreen document all see each other's traffic. Chrome's own offscreen samples route on an explicit target property for exactly this reason. Skip it and the worker's handler will happily pick up the request you meant for the offscreen document, and then pick up the reply as well.
One document at a time, and the race that gets everyone
"Though an extension package can contain multiple offscreen documents, an installed extension can only have one open at a time." (Split incognito mode is the one exception: the normal and incognito profiles get one each.)
Call createDocument() when one is already open and Chrome rejects with Error: Only a single offscreen document may be created. The naive fix, checking first and then creating, is a race: the check is fast, document creation is not, so two events arriving close together both see "no document" and both create. The pattern from Chrome's reference guards with a module-scoped promise:
// background.js
const OFFSCREEN_PATH = '/offscreen.html';
let creating; // a global promise to avoid concurrency issues
async function setupOffscreenDocument() {
const existing = await chrome.runtime.getContexts({
contextTypes: ['OFFSCREEN_DOCUMENT'],
documentUrls: [chrome.runtime.getURL(OFFSCREEN_PATH)]
});
if (existing.length > 0) return;
if (creating) {
await creating;
} else {
creating = chrome.offscreen.createDocument({
url: OFFSCREEN_PATH,
reasons: ['DOM_PARSER'],
justification: 'Parse HTML fetched from the API'
});
await creating;
creating = null;
}
}
Two details worth internalizing. chrome.runtime.getContexts() landed in Chrome 116; if you support older Chrome, fall back to clients.matchAll() inside the worker and look for a client URL ending in your offscreen path. And the promise returned by createDocument() resolves once the document has finished its initial page load, so awaiting it is enough before you send the first message.
Remember that the module-scoped creating variable lives only as long as the worker does. That is fine, because a restarted worker re-runs the getContexts() check, but it is another reminder that nothing in a service worker's memory is durable. If that model is still fuzzy, our post on why a Chrome extension service worker keeps stopping covers the teardown rules.
Lifetime: close it yourself
The reasons you pass decide the lifetime. AUDIO_PLAYBACK closes the document after 30 seconds without audio playing, and the current API reference says all other reasons "don't set lifetime limits", so a DOM_PARSER document you opened generally stays open until you call chrome.offscreen.closeDocument(), the extension reloads, or the browser shuts down. Do not lean on that too hard in either direction: the API's launch announcement described a teardown "similar to event pages in Manifest V2" and warned that "the user agent may place further restrictions on the lifetime specific to the purpose specified". Treat the document as something you open, use, and close, and re-create it when you need it again.
There is a cost to leaving one open. An offscreen document is a full renderer holding memory for a page nobody can see, and Chrome's guidance is explicit that it is not a background page replacement: the primary background context should stay the service worker. Its lifetime is independent of the worker that created it, so a worker restart does not clean it up for you.
What an offscreen document does not unlock
- Extension APIs. "The
runtimeAPI is the only extensions API supported by offscreen documents." Do not plan on callingchrome.storage,chrome.tabs, orchrome.alarmsfrom inside one. Message the worker and let it do that work. - Remotely hosted code. MV3's ban applies to the whole extension, not just the worker. Loading a third-party script tag inside
offscreen.htmlis the same policy violation it would be anywhere else, which is one reason Google Analytics gtag.js still cannot be used in an MV3 extension. - Focus or window tricks. Offscreen documents cannot be focused, and while the page is a real
window, itsopeneris alwaysnull. - Firefox. There is no
browser.offscreen. Firefox's MV3 background is an event page, which is a real document with a DOM, soDOMParserand friends work there directly and no equivalent API is needed. Feature-detect withchrome.offscreenbefore calling it in cross-browser code.
You do not need one for analytics
A useful sanity check before reaching for the API: does the thing you want actually need a DOM, or does it just need the network? HTTP is fully available in a service worker. fetch works, so any analytics, error reporting, or sync that is a POST to an endpoint belongs in the worker with no offscreen document involved.
That is how Moderok is built. The SDK runs directly in the MV3 service worker, sends events with fetch, and keeps its state in chrome.storage.local. There is no document and no window anywhere in its source, so there is nothing in it that needs a page. Save offscreen documents for the jobs that genuinely need a renderer, and keep your background logic where Chrome expects it. If you want install, update, and custom event data out of your extension without adding a page to do it, start with the Moderok SDK.