Every browser extension developer eventually asks the same question: how do I support Firefox without maintaining a completely separate codebase? The promise of “write once, run everywhere” sounds appealing, but anyone who has tried it knows that Chrome and Firefox are not as compatible as they appear.
Here is what the differences actually are, why they matter for architecture, and how to handle them correctly.
The Actual Differences
Manifest format: Chrome uses Manifest V3. Firefox supports MV3 but with differences — notably, Firefox does not support service workers in the same way Chrome does (Firefox uses a background script that behaves differently), and some MV3 APIs Chrome requires are not available in Firefox. You need separate manifests for Chrome and Firefox builds.
API namespace: Chrome extensions use chrome.* APIs. Firefox supports both browser.* (returns Promises) and chrome.* (uses callbacks). If you write code using chrome.* callback-style APIs, it mostly works in Firefox but is not idiomatic. If you write using browser.*, it breaks in Chrome.
Service Worker vs. Background Script: Chrome MV3 requires a service worker as the background page. Firefox’s implementation of MV3 background service workers has historically lagged Chrome’s and has some behavioral differences. Extensions targeting both browsers need to be careful about which service worker lifecycle assumptions they make.
Content Security Policy: Chrome and Firefox handle extension CSPs differently in MV3. Firefox is generally more permissive, but this can cause subtle issues when you expect behavior from one and get it from the other.
browser.action vs. chrome.action: In MV3, the browser action API moved to chrome.action. Firefox uses browser.action. They are functionally equivalent, but the namespace matters.
The Wrong Approach: Runtime Branching
Many developers handle these differences with inline checks:
if (typeof browser !== 'undefined') {
browser.tabs.create({ url })
} else {
chrome.tabs.create({ url })
}
This works for small extensions but does not scale. The branching spreads across your entire codebase, making every extension API call a conditional. When Firefox behavior changes or you add a new browser target, you are hunting for conditionals across dozens of files.
The Right Approach: A Normalized API Layer
The correct architecture is to create a single wrapper function that returns a normalized API object, and use that exclusively in application code:
export function getExtensionApi() {
const api = typeof browser !== 'undefined' ? browser : chrome
return api
}
This is a simplified version. In practice, you also need to handle the Promise vs. callback difference, normalize specific APIs that behave differently, and handle cases where an API exists in one browser but not the other.
LightningAddon ships getExtensionApi() as a fully implemented utility in @repo/browser-utils. Application code imports getExtensionApi() and never touches chrome.* or browser.* directly. When a new browser has an API quirk, you fix it in one place.
Build System: Separate Manifests, Same Source
Beyond the API surface, multi-browser support requires separate manifest files. Your Chrome manifest declares "service_worker" in the background field. Your Firefox manifest declares "scripts": ["background.js"] with "type": "module". The permissions, web accessible resources, and CSP may also differ.
LightningAddon uses a Turborepo monorepo with Vite for each extension context (popup, background, content scripts, in-page app). The build system produces separate output directories for Chrome and Firefox. The commands are:
pnpm build:chrome # produces a Chrome MV3 package
pnpm build:firefox # produces a Firefox-compatible package
Both packages are built from the same TypeScript source. The manifest differences are handled by separate manifest template files that the build system uses during assembly.
Testing Both Targets
Once you have the architecture right, testing is straightforward:
- Chrome: Load the unpacked extension from the Chrome build output directory
- Firefox: Load the extension as a temporary add-on from
about:debuggingusing the Firefox build output directory
LightningAddon’s development mode supports both targets with hot-reload-style rebuilds.
What You Get from the Start
If you are starting a new extension today, setting this architecture up from scratch takes two to three days of careful work — especially the service worker lifecycle differences that bite you when you least expect them.
LightningAddon ships this architecture already working. Clone the repo, run a build, and you have a Chrome package and a Firefox package built from the same codebase. The API normalization, the separate manifests, the build scripts — all done. You contribute to @repo/browser-utils only when you encounter a new edge case.
Jenny Wilson