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.

Published October 2, 2026 Updated October 2, 2026 7 min read
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.

The porting pathAdd a gecko id and Firefox background, load with web-ext and fix warnings, handle host permission grants, replace missing APIs behind detection, test, then sign and submit to AMO.Manifestgecko id, scriptsweb-ext runload and lintHost grantsonboarding promptthen the API gaps and releaseReplace APIsoffscreen, sidePanelTestweb-ext + PlaywrightSign + submitAMO
Most of the work is in the background model and the missing APIs; the rest is configuration.

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.

Common port issues and fixesTypical failures when loading a Chrome MV3 extension in Firefox and the change that fixes each.Chrome assumptionSymptom in FirefoxFixservice_worker backgroundBackground never startsbackground.scriptsNo gecko idAMO upload rejectedbrowser_specific_settingsHosts granted at installContent scripts silentCheck + promptchrome.offscreenTypeErrorUse event page DOMchrome.sidePanelTypeErrorsidebarActionAllow-listed extension originServer rejectsUse tokens, not origins
Most issues are fixed in the manifest or behind a feature check.

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).

Handling a Chrome-only API in FirefoxDecision tree: if Firefox has an equivalent, adapt to it; if the event page DOM covers it, use that; if there is no equivalent, degrade the feature or hide it.Does Firefox have an equivalent?different shapeAdaptersidePanel → sidebarActionShared pagesame HTMLDOM covers itUse event page DOMoffscreen → documentDetect documentnot the browsernoneDegrade or hidereadingList, getAuthTokenSay so in UIno dead buttons
Adapt where an equivalent exists; degrade honestly where it does not.

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_worker only. Older Firefox MV3 versions do not start it; generate scripts.
  • 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, no offscreen, blocking webRequest retained. 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

  1. web-ext lint reports no errors.
  2. The extension loads in Firefox, the background console shows startup logs, and every feature works or is visibly unavailable.
  3. Revoke host permissions in about:addons and confirm the extension prompts rather than failing silently.
  4. The signed .xpi installs 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.

Other Core APIs & Cross-Browser Data Management Resources