Chrome and Firefox from a Single Codebase: A 2026 Developer Guide

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"

  • Jenny Wilson Jenny Wilson
  • date icon

    Thursday, Mar 05, 2026

Chrome and Firefox from a Single Codebase: A 2026 Developer Guide

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:debugging using 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.

Blog

Read More Posts

Your Trusted Partner in Data Protection with Cutting-Edge Solutions for
Comprehensive Data Security.

How to Get Your First 100 Paying Users for a Browser Extension
date icon

Tuesday, Mar 17, 2026

How to Get Your First 100 Paying Users for a Browser Extension

Most browser extension SaaS products never reach 100 paying users — not because they are bad products, but because the d

Read More
Supabase vs Firebase for Your Extension Backend: A Practical 2026 Comparison
date icon

Sunday, Mar 15, 2026

Supabase vs Firebase for Your Extension Backend: A Practical 2026 Comparison

If you are starting a browser extension SaaS, you need a backend for auth, data storage, and Stripe webhook handling. Su

Read More
How to Update Your Extension Without Breaking Existing Users
date icon

Saturday, Mar 14, 2026

How to Update Your Extension Without Breaking Existing Users

Browser extensions store data in chrome.storage.local and chrome.storage.sync. Unlike a server-side database, you do

Read More
LightningAddon Chrome, Firefox & Safari extension framework call to action

Stop Rebuilding the Same Foundation. Start Shipping.

Auth, billing, multi-browser builds, typed messaging — every extension SaaS needs the same boring foundation. LightningAddon ships all of it, battle-tested and ready in 15 minutes. Pay once, own it forever.

Get LightningAddon