Firefox Manifest V3 Uses an Event Page, Not a Service Worker
6 min readModerok team
Porting a Chrome MV3 extension to Firefox? Its background runs as an event page, not a service worker. Here is what changes and what stays the same.
If you built a Chrome Manifest V3 extension and are now porting it to Firefox, the first surprise is the background. Firefox MV3 does not run your background code as a service worker. It runs it as an event page: a non-persistent background page declared with background.scripts, not background.service_worker. Chrome only accepts service_worker; Firefox (through at least Firefox 128 and the current release) only starts scripts. The good news is that one manifest can satisfy both, and most of the hard MV3 lessons you already learned still apply.
Here is the direct answer, then the details.
The one manifest that works in both browsers
List both keys under background:
{
"manifest_version": 3,
"background": {
"service_worker": "background.js",
"scripts": ["background.js"],
"type": "module"
}
}
Chrome reads service_worker and ignores scripts. Firefox reads scripts and ignores service_worker. You point them at the same file, and one build ships to both stores.
Two version caveats are worth knowing, one on each side. Before Chrome 121, Chrome refused to load a Manifest V3 extension that had background.scripts (or background.page) present at all; from Chrome 121 onward it simply ignores them. Symmetrically, before Firefox 120, Firefox would refuse to start the background page if a service_worker key was present, so the combined manifest above silently did nothing there; from Firefox 121 onward the background page starts regardless of the service_worker key. Both cutovers landed at version 121, long past on any current Chrome or Firefox, so the both-keys manifest is the standard cross-browser pattern today. If you still support very old builds of either browser, test there.
"type": "module" is honored in both, so you can use import in either environment.
What an event page actually is
An event page is the MV2 non-persistent background page carried forward: persistent: false behavior, but now the default and only option in MV3. It is a real HTML page under the hood, which is the single biggest practical difference from a Chrome service worker.
A Chrome MV3 service worker has no DOM. There is no window, no document, no DOMParser, no localStorage, no XMLHttpRequest bound to a document. An event page has all of those, because it is a page. Code that parses HTML with DOMParser, or that reaches for document, will run in Firefox and throw in Chrome. If your goal is one shared file, write to the lowest common denominator: assume no DOM, use fetch instead of XMLHttpRequest, and keep DOM work in a popup, options page, or offscreen document.
Like a service worker, an event page is not persistent. Firefox unloads it when it goes idle and restarts it on demand when an event it listens for fires. So the mental model you built fighting Chrome's 30-second teardown carries straight over: your background can vanish between events, and any in-memory state you did not persist is gone. If you have not internalized that model yet, our write-up on why a Chrome service worker keeps stopping covers the lifecycle in detail, and the same discipline keeps a Firefox event page correct.
The MV3 rules that bite identically in both
Two habits you learned for Chrome service workers are not Chrome-specific. They are event-driven-background rules, and Firefox enforces them too.
Register listeners at the top level, synchronously. Both browsers wake a sleeping background by dispatching the event to a listener that was registered during the initial, synchronous run of the script. If you add a listener inside a promise callback or after an await, the background can be asleep when the event fires, the listener will not exist yet, and the event is lost. Put chrome.runtime.onInstalled, chrome.runtime.onMessage, and friends at the top of the file, not inside init() after the first await.
Timers do not survive idle. A setTimeout or setInterval you scheduled is discarded when the background unloads, in both browsers. If you need something to happen in five minutes, use chrome.alarms, which is designed to wake a sleeping background. This is explicit in Firefox's event page guidance and identical to Chrome's service worker behavior.
onInstalled is your one-time setup hook. Both browsers fire chrome.runtime.onInstalled when the extension is installed or updated. It is the right place to create context menus, seed storage defaults, or record a first-run event, precisely because it runs once and does not depend on a page being open.
chrome vs browser, callbacks vs promises
The namespace difference trips people up more than the manifest does. Firefox exposes extension APIs under the browser namespace and returns promises. Chrome exposes chrome, historically with callbacks. To ease porting, Firefox also provides the chrome namespace as an alias, so a Chrome-style chrome.storage.local.get(keys, cb) runs unmodified in Firefox.
The subtle part is promises. In Chrome under Manifest V3, most chrome.* async methods return a promise when you omit the callback, so await chrome.storage.local.get(keys) resolves to the data. In Firefox, that same await on the chrome alias does not work: Firefox's chrome namespace is callback-based, and only browser.* returns promises. So await chrome.storage.local.get(keys) is fine in Chrome but not through Firefox's chrome alias.
You have two clean options:
- Use
browser.*everywhere and add Mozilla'swebextension-polyfill, which providesbrowser.*in Chrome and is a no-op in Firefox, wherebrowseralready exists. Promises then behave the same in both. - Or use the callback form of
chrome.*and wrap it in your own promise. The callback signature behaves identically in Chrome and Firefox, sochrome.storage.local.get(keys, cb)is the one call guaranteed to be portable.
Pick one namespace and one async style for your codebase and stay consistent. Mixing browser.foo().then() with chrome.foo(cb) in the same file is where cross-browser bugs hide.
What this means for analytics
An analytics SDK that assumes a service worker global will misbehave inside a Firefox event page, so the design choices matter. Moderok's SDK calls the chrome.* namespace directly (for example chrome.runtime.onInstalled, chrome.runtime.getManifest, and chrome.storage.local), which Firefox aliases, so the same build loads in a Chrome service worker and a Firefox event page without a separate bundle.
It also follows the top-level rule: the SDK registers its onInstalled listener synchronously inside init() before any await, so install and update events can be queued when either background context wakes. On the promise question above, it takes the portable path: it calls the callback form of the storage APIs and wraps each call in its own promise. Where an API might be missing, it feature-detects rather than assuming: the SDK checks for chrome.runtime.setUninstallURL before calling it and skips the call where the method is unavailable. We covered the cross-browser nuances of uninstall tracking in tracking Chrome extension uninstalls with setUninstallURL.
The SDK also reads the user agent to tag each event with the browser (chrome, firefox, edge, or other Chromium builds). That browser value is preserved in raw event context; the current dashboard does not group its charts by browser.
The short version
Firefox MV3 keeps the event page and its DOM; Chrome MV3 uses a DOM-less service worker. Declare both service_worker and scripts in the background key to ship one manifest. Write your background as if there is no DOM so the shared file runs in Chrome, register every listener at the top level, and reach for chrome.alarms instead of setTimeout. Do that and the Firefox port is mostly a namespace decision, not a rewrite.
If you want automatic install, update, and daily-active events with browser context attached, Moderok ships a 5.7 kB gzipped SDK with zero runtime dependencies and no host_permissions. Chromium is the primary target, with Firefox 109 and later documented for cross-browser builds.