Porting a Chrome Extension to Firefox
Port an MV3 Chrome extension to Firefox: gecko id, background scripts instead of service_worker, host permission grants, missing APIs like offscreen and sidePanel, web-ext for testing and AMO signing.
Table of Contents
The Chrome extension works and has users. Loading the same folder in Firefox through about:debugging produces a list of warnings, a background script that never starts, and features that silently do nothing. Firefox implements the WebExtensions API and supports Manifest V3, so most code runs unchanged — but the differences are concentrated in exactly the places a Chrome-first extension leans on: the background model, host permissions, and a handful of Chrome-only APIs. This guide works through a port from first load to AMO submission. It belongs to cross-browser API compatibility.
Where Chrome and Firefox actually differ
Firefox’s MV3 differs from Chrome’s in four areas that matter for a port. The background runs as an event page declared with background.scripts, not a service worker — it has a DOM, is suspended when idle, and is woken by events like a worker. Host permissions are revocable by the user at any time and, before Firefox 127, were not granted at install at all. Several Chrome APIs are absent: offscreen, sidePanel (Firefox has sidebar_action), declarativeContent, identity.getAuthToken, readingList. And a few behave differently: blocking webRequest still works, content scripts with host permission bypass CORS, and the extension’s origin is a random per-install UUID. The rest — storage, runtime messaging, tabs, scripting, alarms, context menus, DNR basics — works as in Chrome.
Step-by-step: from Chrome build to AMO
1. Generate a Firefox manifest
1// build/manifest.firefox.mjs
2export function toFirefox(chromeManifest) {
3 const m = structuredClone(chromeManifest);
4 m.background = { scripts: [m.background.service_worker], type: m.background.type };
5 m.browser_specific_settings = { gecko: { id: "readable@acme.example", strict_min_version: "121.0" } };
6 if (m.side_panel) {
7 m.sidebar_action = { default_panel: m.side_panel.default_path, default_title: m.name };
8 delete m.side_panel;
9 }
10 delete m.minimum_chrome_version;
11 delete m.key;
12 m.permissions = m.permissions.filter((p) => !["sidePanel", "offscreen", "readingList"].includes(p));
13 return m;
14}
Execution context: the build pipeline. The same background file runs as an event page; registering listeners at the top level, as the service worker required, works identically. Dropping Chrome-only permissions removes AMO lint warnings. The gecko id must be fixed before the first upload and never changed — see browser-specific settings for Firefox and Safari.
2. Load and lint with web-ext
1npx web-ext lint --source-dir dist/firefox
2npx web-ext run --source-dir dist/firefox --firefox=firefoxdeveloperedition --browser-console
Execution context: a terminal. lint runs AMO’s validator locally; run starts Firefox with a temporary profile and the extension loaded, opens the Browser Console for background logs, and reloads on file changes. Fix every lint error before going further; warnings about unknown Chrome keys are informational.
3. Handle host permissions that may be missing
1// background.js
2browser.runtime.onInstalled.addListener(async () => {
3 const ok = await browser.permissions.contains({ origins: ["https://*.example.com/*"] });
4 if (!ok) await browser.tabs.create({ url: browser.runtime.getURL("onboarding.html#grant") });
5});
Execution context: the Firefox background. Even on Firefox 127+, where hosts are requested at install, users can revoke them per host from about:addons. An onboarding page that requests them from a button click, and a badge when they are missing, cover both cases — see host permissions in Firefox MV3 are optional.
4. Replace Chrome-only APIs behind detection
1// dom-work.js — parse HTML: offscreen in Chrome, event page DOM in Firefox
2export async function parseHtml(html) {
3 if (typeof document !== "undefined") {
4 return new DOMParser().parseFromString(html, "text/html").title; // Firefox event page
5 }
6 await ensureOffscreen("DOM_PARSER"); // Chrome service worker
7 return chrome.runtime.sendMessage({ target: "offscreen", type: "parse-title", html });
8}
Execution context: the background in each browser. Detecting document rather than the browser name means the code also works if Firefox later runs the background as a service worker. Apply the same pattern to side panels (sidePanel versus sidebarAction), declarativeContent (replace with tabs.onUpdated) and sign-in (launchWebAuthFlow instead of getAuthToken).
5. Watch for behaviours that are more permissive in Firefox
1// Works in Firefox, fails in Chrome: content-script cross-origin fetch with host permission
2// Portable: always proxy through the background
3const data = await browser.runtime.sendMessage({ type: "lookup", term });
Execution context: a content script. Firefox’s extra privileges — content scripts bypassing CORS, blocking webRequest, filterResponseData — make it easy to write Firefox-only code by accident. Keep shared code to the Chrome subset and put Firefox enhancements in clearly marked modules.
6. Test in Firefox automatically
1// playwright.config.js — Firefox extension testing goes through web-ext or a prepared profile
2// A simple smoke test with web-ext:
3// npx web-ext run --target=firefox-desktop --start-url=https://example.com --no-reload
Execution context: CI. Playwright’s Firefox does not load extensions directly the way Chromium does; web-ext with Selenium/geckodriver, or web-ext run for smoke tests, are the common routes. See testing extensions in Firefox with web-ext.
7. Sign and submit to AMO
1npx web-ext sign --source-dir dist/firefox --channel listed \
2 --api-key "$AMO_JWT_ISSUER" --api-secret "$AMO_JWT_SECRET"
Execution context: a terminal or CI with AMO API credentials. Listed submissions go through AMO review; minified or bundled code requires a source-code upload with build instructions so reviewers can reproduce the package. Unlisted signing produces a signed .xpi for self-distribution. See signing and publishing to AMO from CI.
Common mistakes
- Keeping
background.service_workeronly. Older Firefox MV3 versions do not start it; generatescripts. - Forgetting the gecko id. Storage and native messaging change on every temporary load, and AMO rejects the upload.
- Assuming hosts are granted. Users can revoke them; check and prompt.
- Relying on Firefox’s extra privileges in shared code. The Chrome build breaks.
- Skipping source upload for bundled code. AMO review stalls without reproducible sources.
Cross-browser variation
- Chrome / Edge: the baseline this port starts from.
- Firefox: event page background, gecko id, revocable hosts,
sidebar_action, nooffscreen, blockingwebRequestretained. Firefox for Android supports a subset; test there separately if you list it. - Safari: a separate port through Xcode; see porting a Chrome extension to Safari with Xcode.
Verification
web-ext lintreports no errors.- The extension loads in Firefox, the background console shows startup logs, and every feature works or is visibly unavailable.
- Revoke host permissions in
about:addonsand confirm the extension prompts rather than failing silently. - The signed
.xpiinstalls on a clean Firefox profile.
FAQ
Can I use chrome.* in Firefox?
Yes. Firefox provides chrome as an alias with promise support in MV3. Using one namespace throughout is simpler than mixing.
Do I need the WebExtension polyfill?
Not for MV3, where both engines return promises. It remains useful only for older Chrome callback-style code.
Does Firefox enforce the same CSP?
Yes, MV3’s extension-page CSP minimum applies, and AMO policy forbids remote code.
How do I handle a feature Firefox implements differently, like context menus?
Firefox calls the API menus and also exposes it as contextMenus. It adds menus.onShown and menus.refresh(), which let you update items just before the menu appears — something Chrome cannot do. Use the shared contextMenus subset in common code and add onShown refinements behind a feature check, as described in context menus in Firefox and Safari.
What about Firefox for Android?
It supports MV3 extensions with a reduced API surface — no windows management, limited sidebar_action, different popup presentation — and requires the add-on to be marked Android-compatible on AMO. Test on a device or emulator with web-ext run --target=firefox-android before enabling it.
Related
- Shipping one manifest for Chrome and Firefox — keeping the build single-sourced.
- Chrome vs Firefox vs Safari API gap reference — the full gap list.
- Publishing to Firefox Add-ons and Safari — the store side.
- Cross-browser API compatibility — the parent topic.