Using the webextension-polyfill in MV3
Add Mozilla's webextension-polyfill to a Manifest V3 extension so one promise-based browser.* codebase runs on Chrome, Firefox and Safari — including in service workers and content scripts.
Table of Contents
Chrome’s chrome.* namespace returns promises for most APIs now, so the polyfill looks redundant until you hit the parts that still do not, the callbacks that report errors through chrome.runtime.lastError instead of rejecting, and the Firefox build where browser.* is the native namespace. The polyfill’s job is to make one namespace, with one error convention, work everywhere. This guide is part of cross-browser API compatibility.
What the polyfill actually normalises
It is not a shim for missing APIs — it will not give Chrome browser.sidebarAction or Firefox chrome.sidePanel. It wraps the callback-style chrome.* surface in promises and routes lastError into a rejection, so the two engines agree on shape while you still handle capability differences yourself, as described in feature detection instead of browser sniffing.
Step-by-step
1. Install and import it as a module
1npm install webextension-polyfill
Execution context: your local shell. The package ships both a CommonJS build and a browser bundle; a bundler is the simplest way to get the right one into each entry point.
1// service-worker.js
2import browser from "webextension-polyfill";
3
4browser.runtime.onInstalled.addListener(async () => {
5 await browser.storage.local.set({ installedAt: Date.now() });
6});
Execution context: the MV3 service worker, which must be declared with "type": "module" for this import to work. The polyfill throws at import time if it cannot find a chrome.runtime.id, which is its way of telling you it was loaded outside an extension context.
2. Declare the worker as a module
1{
2 "manifest_version": 3,
3 "background": {
4 "service_worker": "service-worker.js",
5 "type": "module" // required for `import` in the worker
6 }
7}
Execution context: parsed by the browser at install time. Firefox accepts "type": "module" from version 121; earlier Firefox builds need a bundled worker with no bare imports. Safari follows Chrome here.
3. Bundle it into content scripts explicitly
Content scripts do not support ES module imports from the manifest, so the polyfill must be bundled into the script file or listed ahead of it in "js".
1{
2 "content_scripts": [{
3 "matches": ["https://*.example.com/*"],
4 "js": ["vendor/browser-polyfill.js", "content.js"]
5 }]
6}
Execution context: the content script’s isolated world. Loading the polyfill as a separate file means it defines a global browser that content.js then uses; bundling instead gives each script its own copy, which is safer if two of your scripts run on the same page.
4. Convert error handling to rejections
The behavioural win is here. Chrome’s callback APIs report failure by setting chrome.runtime.lastError, which is silently swallowed if you never read it.
1// Before — the failure is invisible unless you remember lastError
2chrome.tabs.sendMessage(tabId, { type: "ping" }, (reply) => {
3 if (chrome.runtime.lastError) return; // easy to forget
4 handle(reply);
5});
6
7// After — a normal rejection you cannot forget
8try {
9 const reply = await browser.tabs.sendMessage(tabId, { type: "ping" });
10 handle(reply);
11} catch (err) {
12 // "Could not establish connection. Receiving end does not exist."
13 console.debug("[ping] no content script in tab", tabId, err.message);
14}
Execution context: the service worker. The rejection message text differs between engines — Chrome says “Receiving end does not exist”, Firefox says “Could not establish connection” — so match loosely if you branch on it.
5. Keep one escape hatch for Chrome-only APIs
Some surfaces have no browser.* equivalent because they do not exist off Chrome. Reach for chrome.* directly there, guarded by a capability check, rather than pretending the polyfill covers it.
1export const hasSidePanel = typeof chrome !== "undefined" && !!chrome.sidePanel;
2
3export async function openPanel(tabId) {
4 if (!hasSidePanel) return openFallbackTab();
5 await chrome.sidePanel.open({ tabId }); // Chrome-only, deliberately un-polyfilled
6}
Execution context: the service worker. chrome.sidePanel.open additionally requires a user gesture; the constraint is covered in opening the side panel from a user gesture.
What it costs, and where it does not help
The polyfill is small — a few kilobytes once minified and gzipped — but it is loaded into every context that imports it, and in an extension that means the service worker on every cold start, each content script on every matching page, and each extension page on open. On a page with your content script running in forty iframes, that is forty copies.
The mitigation is to scope it. A content script that only ever calls chrome.runtime.sendMessage does not need the polyfill at all: sendMessage returns a promise natively in Chrome and in Firefox, and the error convention difference does not arise for a fire-and-forget message.
1// content.js — no polyfill needed for this surface
2const api = globalThis.browser ?? globalThis.chrome;
3await api.runtime.sendMessage({ type: "page:seen", url: location.href });
Execution context: the content script’s isolated world. The ?? picks Firefox’s native namespace where it exists and Chrome’s where it does not; both return a promise for sendMessage, so no wrapper is required.
More important is knowing what the polyfill does not do, because assuming otherwise produces bugs that only appear off Chrome:
- It does not add missing APIs.
browser.sidePanelstays undefined on Firefox. Capability checks remain your responsibility. - It does not normalise event semantics. The
return truecontract foronMessageis unchanged; Firefox still accepts a returned promise and Chrome still does not. - It does not unify manifest keys.
background.service_workerversusbackground.scriptsis a build-time concern, covered in generating a manifest per browser target. - It does not change quotas or limits. Storage caps, alarm minimums and rule counts differ per engine regardless.
There is also one behaviour worth knowing for tests: the polyfill throws at import time if chrome.runtime.id is not readable. That is deliberate — it is how it detects being loaded outside an extension — but it means a unit test that imports a module transitively importing the polyfill fails before your test body runs.
Cross-browser variation
- Chrome / Edge: most
chrome.*APIs now return promises natively when no callback is passed, so the polyfill’s main remaining value is uniform error handling and one namespace across your codebase. - Firefox:
browser.*is native and already promise-based. The polyfill detects this and re-exports the native object, so there is no wrapper cost beyond the import. - Safari: exposes both
browser.*andchrome.*, withbrowser.*promise-based. The polyfill works, but some APIs exist in name only and resolve with empty results — capability checks still matter, as covered in shipping one manifest for Chrome and Firefox. - All three: the polyfill does not wrap
chrome.runtime.onMessagereturn semantics. Returningtrueto keep a channel open is still the Chrome contract; returning a promise is still the Firefox one. Use an explicitsendResponsein shared code.
Verification
- Load the extension in Chrome and, in the service worker console, confirm the namespace resolves and returns a promise:
1browser.storage.local.get("installedAt") instanceof Promise; // true
Execution context: the service worker console, where the polyfill’s browser is in scope only if your bundle exposed it. If it is undefined, the module was tree-shaken out — reference it from real code, not just from the import.
- Trigger a message to a tab with no content script and confirm you get a rejection with a message rather than a silent
undefinedreply. - Load the same build in Firefox with
web-ext runand confirm identical behaviour without a second code path — the check described in testing extensions in Firefox with web-ext.
FAQ
Is the polyfill still worth adding in 2026?
If you ship to Firefox or Safari as well as Chrome, yes — it removes a whole class of divergence for the cost of a few kilobytes. If you are Chrome-only, the native promise support means you can skip it and just avoid callbacks.
Does it work inside an MV3 service worker?
Yes, provided the worker is declared with "type": "module" or the polyfill is bundled into the worker file. It does not work if you try to load it with importScripts, which MV3 module workers do not support.
Why does it throw “This script should only be loaded in a browser extension”?
Because chrome.runtime.id was not readable at import time — usually a unit test importing your module outside an extension context. Mock the namespace in your test setup, as in mocking Chrome APIs in Jest.
Related
- Feature detection instead of browser sniffing — the capability checks the polyfill does not replace.
- Shipping one manifest for Chrome and Firefox — the manifest side of the same problem.
- Typing chrome and browser APIs in TypeScript — getting the two namespaces to type-check together.
- Cross-browser API compatibility — the parent reference for engine differences.