Cross-Browser Extension Development in 2026: The Honest Guide Nobody Else Will Write

AppBooster Team · · 12 min read
Multiple browser logos representing cross-browser extension development

You shipped your Chrome extension. It works perfectly. Twelve hundred users love it. Then someone files a GitHub issue: “Does this work on Firefox?”

You open manifest.json. You look at your service worker. You remember that Firefox doesn’t support Manifest V3 service workers the same way Chrome does. You feel a familiar dread.

This is cross-browser extension development in 2026 — and most articles about it are either two years out of date or written by people who’ve never actually shipped across all four major browsers.

This one’s different. We’ll cover what actually works: the polyfills, the manifest differences, the tooling, the store submission quirks, and the exact patterns that save you from rewriting your extension three times.


The Landscape (And Why It’s More Complex Than You Think)

Chrome owns 71.37% of the global browser market — 85% on desktop. There are over 111,933 extensions in the Chrome Web Store. But here’s the number that should shift your thinking: 3.45 billion people use browser extensions worldwide.

That’s not a Chrome-only audience.

The Chromium ecosystem (Chrome, Edge, Brave, Opera, Arc) covers roughly 83% of browsers. Firefox is a distant but loyal second. Safari is tricky — it requires Xcode and Apple’s blessing — but it’s the only option on iOS and a default for millions of macOS users who never switched.

86.3% of extensions have fewer than 1,000 users. If you’re building for reach, cross-browser isn’t optional. It’s the strategy.

Browser market share breakdown showing Chrome dominance with Firefox, Safari, and Edge as meaningful secondary markets The browser market in 2026 — Chrome leads, but the other 17% represents hundreds of millions of users


The API Compatibility Problem

Here’s the core tension: extensions are supposed to use the WebExtensions API, a standard that Chrome, Firefox, Edge, and Safari all claim to support. In practice, you’ll run into three categories of pain.

1. Namespace differences

Chrome uses chrome.*. Firefox uses browser.*. Edge uses chrome.* (it’s Chromium). Safari uses browser.*.

// This works on Chrome, breaks on Firefox
chrome.storage.local.get({ theme: "light" }, (result) => {
  console.log(result.theme);
});

// This works on Firefox, breaks on Chrome
browser.storage.local.get({ theme: "light" }).then((result) => {
  console.log(result.theme);
});

One uses callbacks. One returns Promises. Pick a side and you alienate half your target browsers.

The fix: mozilla/webextension-polyfill

This library is the most important dependency in any cross-browser extension project. It wraps the chrome.* API in a Promise-based browser.* namespace, unified across all browsers.

npm install webextension-polyfill
// In your background script or content script
import browser from "webextension-polyfill";

// Now this works identically on Chrome, Firefox, Edge, and Safari
const result = await browser.storage.local.get({ theme: "light" });
console.log(result.theme);

One API. All browsers. Zero callback pyramid.

2. Manifest V3 differences

Chrome forced Manifest V3 adoption. Firefox supports MV3 but intentionally kept MV2 alive as a developer retention strategy — they know MV3 blocks some legitimate use cases (ad blockers, privacy tools) and they’ve been explicit about not wanting to repeat Chrome’s controversial rollout.

Safari supports MV3 but with a different subset of APIs. Edge follows Chrome nearly identically since it’s Chromium-based.

The biggest practical difference:

FeatureChrome (MV3)Firefox (MV3)Safari (MV3)Edge (MV3)
Service WorkersYesYes (with quirks)Yes (limited)Yes
Background PagesNoStill supports MV2NoNo
declarativeNetRequestFullPartialPartialFull
webRequest blockingNoYes (MV2)NoNo
Dynamic content scriptsYesYesLimitedYes
browser.actionYesYesYesYes

3. Browser-specific API gaps

Some APIs simply don’t exist everywhere. Don’t assume. Detect.


Feature Detection: The Right Pattern

Never do browser sniffing. You’ll get it wrong, and it’ll break when Brave updates its UA string or when Edge adds an API Firefox doesn’t have yet.

Do this instead:

// Bad: browser sniffing
const isFirefox = navigator.userAgent.includes("Firefox");
if (!isFirefox) {
  chrome.declarativeNetRequest.updateDynamicRules(/* ... */);
}

// Good: feature detection
if (browser.declarativeNetRequest?.updateDynamicRules) {
  await browser.declarativeNetRequest.updateDynamicRules({
    addRules: rules,
    removeRuleIds: [],
  });
} else {
  // Fallback for browsers that don't support it
  console.warn("declarativeNetRequest not available, skipping rule update");
}

Same pattern for permissions:

async function requestOptionalPermission(permission) {
  try {
    const granted = await browser.permissions.request({
      permissions: [permission],
    });
    return granted;
  } catch (e) {
    // Safari throws on some permission requests instead of returning false
    console.warn(`Permission request failed: ${e.message}`);
    return false;
  }
}

This is more verbose. It’s also correct.

Code editor showing cross-browser extension API compatibility patterns with polyfill integration Feature detection over browser sniffing — write it once, run it everywhere


Tooling: WXT vs Plasmo vs Extension.js in 2026

You could write a cross-browser extension with zero tooling. A manifest.json, some JS files, and a prayer. But if you want a dev server, HMR, TypeScript support, and auto-manifest generation, you need a build tool.

Three serious contenders exist in 2026.

wxt.dev is Vite-based, has the fastest HMR in any extension framework (~200ms), and handles cross-browser manifest generation automatically. It’s the framework we recommend after testing all three.

npx wxt@latest init my-extension
cd my-extension
npm run dev        # Opens Chrome with HMR
npm run dev:firefox # Opens Firefox with HMR
npm run build      # Builds for all targets
npm run zip        # Creates store-ready ZIPs

WXT’s wxt.config.ts is where cross-browser targeting gets elegant:

// wxt.config.ts
import { defineConfig } from "wxt";

export default defineConfig({
  manifest: {
    name: "My Extension",
    description: "Does the thing",
    permissions: ["storage", "tabs"],
  },
  // Browser-specific manifest overrides
  manifestVersion: 3,
  browser: "chrome", // or "firefox", "safari", "edge"
});

You can also use WXT’s browser object directly in code — it re-exports webextension-polyfill with full TypeScript types.

Plasmo — React DX, Aging Internals

Plasmo is popular with React developers because it treats your popup like a React component and wires everything up automatically. The DX is genuinely pleasant for simple extensions.

The problem: Plasmo still uses the Parcel bundler. In 2026, Parcel is slow, and Plasmo’s update cadence has slowed considerably. Build times average 3-4x slower than WXT. If you’re starting a new project, start with WXT. If you’re already on Plasmo and it’s working, don’t rewrite unless you have a reason.

Extension.js — The Emerging Contender

Extension.js is growing fast and makes one bold promise: zero config, maximum compatibility. No manifest file needed initially. Drop in a folder, it figures it out.

npx extension@latest dev --browser=firefox my-extension/

It’s not mature enough for production complex extensions yet. But watch it — the zero-config philosophy is compelling for teams that want to move fast without fighting tooling.


Writing Cross-Browser Manifests

The manifest is where most developers lose hours. Here’s a practical pattern for handling browser differences without forking your entire codebase.

Use a base manifest and extend it per browser:

// manifest.base.json
{
  "manifest_version": 3,
  "name": "My Extension",
  "version": "1.0.0",
  "description": "Does the thing",
  "permissions": ["storage"],
  "action": {
    "default_popup": "popup.html",
    "default_icon": {
      "16": "icons/icon-16.png",
      "48": "icons/icon-48.png",
      "128": "icons/icon-128.png"
    }
  }
}
// manifest.firefox.json — overrides for Firefox
{
  "browser_specific_settings": {
    "gecko": {
      "id": "myextension@yourdomain.com",
      "strict_min_version": "109.0"
    }
  }
}

Firefox requires a browser_specific_settings.gecko.id field or it won’t let you publish to AMO (the Firefox Add-ons store). This trips up almost everyone the first time.

If you’re not using WXT’s auto-manifest, merge these at build time:

// build.js
const base = require("./manifest.base.json");
const browserOverrides = require(`./manifest.${process.env.BROWSER}.json`);
const merged = { ...base, ...browserOverrides };
fs.writeFileSync("dist/manifest.json", JSON.stringify(merged, null, 2));

The Safari Problem

Safari deserves its own section because it’s genuinely different from every other browser in the extension ecosystem.

Safari extensions are distributed through the Mac App Store (and iOS App Store). To convert a WebExtension to Safari format:

# Requires Xcode installed
xcrun safari-web-extension-converter /path/to/your/extension \
  --project-location ~/Projects \
  --app-name "My Extension"

This generates an Xcode project. You then need to:

  1. Open it in Xcode
  2. Set your Apple Developer Team ID
  3. Build and archive
  4. Submit to App Store Connect

No Apple Developer account ($99/year), no Safari extension. That’s the wall.

Safari API limitations to know:

  • No webRequest API at all (not even in modified form)
  • browser.storage.session not supported as of early 2026
  • Content script injection timing can differ — test thoroughly
  • declarativeNetRequest support is partial — verify your specific rule types

For extensions where Safari is important to your users, build and test it from day one. Retrofitting Safari support into a Chrome-native extension is significantly more painful than designing for it upfront.

Safari browser on macOS showing extension preferences panel with Xcode development environment Safari extension development requires Xcode — plan for it early, not as an afterthought


Store Submission: Timeline Reality Check

This is what nobody tells you until you’ve missed a launch deadline.

StoreReview TimeAPI Key RequiredCost
Chrome Web Store3 days (avg)No$5 one-time
Microsoft Edge Add-ons7 days (avg)NoFree
Firefox Add-ons (AMO)1-3 days (auto for listed)Yes — AMO APIFree
Apple App Store (Safari)1-3 days (with variations)No$99/year developer

Firefox AMO requires API credentials for automated submission. Get them at addons.mozilla.org/developers/ before you build your CI/CD pipeline.

For CWS: your first submission after any major permission change triggers manual review. Budget extra time.

The submission script pattern (using web-ext for Firefox + WXT for Chrome/Edge):

# Firefox submission
npx web-ext sign \
  --api-key $AMO_JWT_ISSUER \
  --api-secret $AMO_JWT_SECRET \
  --source-dir ./dist/firefox

# Chrome submission (via Chrome Web Store API)
curl -X PUT \
  -H "Authorization: Bearer $CHROME_ACCESS_TOKEN" \
  -H "x-goog-api-version: 2" \
  -T ./dist/chrome.zip \
  "https://www.googleapis.com/upload/chromewebstore/v1.1/items/$EXTENSION_ID"

There’s no unified CI/CD solution for cross-browser extension submission in 2026 — you’re assembling it yourself. Tools like ExtensionBooster’s free developer tools can help streamline parts of this workflow, especially around store listing optimization.


Cross-Browser Testing Without Losing Your Mind

Automated cross-browser extension testing is the biggest gap in the ecosystem right now. There’s no unified solution. Here’s the pragmatic approach:

Unit testing — works fine with Vitest or Jest. Mock the browser API:

// vitest.setup.ts
import { vi } from "vitest";

global.browser = {
  storage: {
    local: {
      get: vi.fn().mockResolvedValue({}),
      set: vi.fn().mockResolvedValue(undefined),
    },
  },
  tabs: {
    query: vi.fn().mockResolvedValue([]),
  },
} as any;

E2E testing — Playwright supports Chrome extensions:

import { test, chromium } from "@playwright/test";
import path from "path";

test("popup renders correctly", async () => {
  const pathToExtension = path.join(__dirname, "../dist/chrome");
  const context = await chromium.launchPersistentContext("", {
    headless: false,
    args: [
      `--disable-extensions-except=${pathToExtension}`,
      `--load-extension=${pathToExtension}`,
    ],
  });
  // ... test your popup
  await context.close();
});

Firefox E2E testing with Playwright requires firefox-web-ext and is more brittle. For now, most teams do Firefox testing manually or with web-ext run in CI for smoke tests.


The Architecture Decision You Make Once

Before you write a line of code, decide how you’ll handle browser differences at the architecture level.

Option A: Single codebase, runtime detection

Use webextension-polyfill + feature detection everywhere. One build, multiple manifests. This works well for 80% of extensions.

Option B: Single codebase, build-time branching

Use environment variables and tree-shaking to exclude browser-specific code per target build. WXT supports this natively.

// Only included in Firefox builds
if (import.meta.env.BROWSER === "firefox") {
  // Firefox-specific initialization
}

Option C: Separate codebases

Don’t do this unless Safari’s constraints are so different that sharing code is impossible. Maintaining two codebases means twice the bugs and half the velocity.

Option A or B. Decide early.

Developer planning cross-browser extension architecture on a whiteboard with browser compatibility matrix Decide your cross-browser architecture before writing a line of code — retrofitting it costs 3x as much


The Quick-Start Checklist

If you’re starting a new cross-browser extension today:

Setup

  • Initialize with WXT (npx wxt@latest init)
  • Install webextension-polyfill (WXT includes it, verify it’s active)
  • Create manifest.firefox.json with gecko ID from day one
  • Set up build scripts for all target browsers

Code

  • Use browser.* (via polyfill), never chrome.* directly
  • Feature-detect every non-core API before calling it
  • Wrap permission requests in try/catch (Safari throws)
  • Test service worker persistence — Firefox handles termination differently

Store Prep

  • Get AMO API keys (Firefox) — takes up to 24 hours to activate
  • Register Apple Developer account if shipping Safari
  • Build Chrome extension first, submit to CWS to test the pipeline
  • Budget 7-10 days for first cross-browser launch (review queues add up)

Ongoing

  • Check ExtensionBooster’s free developer tools for store listing analysis across browsers
  • Pin your webextension-polyfill version — minor updates occasionally break API shapes
  • Subscribe to Firefox developer blog — they announce MV3 changes separately from Chrome

What This Looks Like At Scale

The hard truth: cross-browser extension development in 2026 is better than it was in 2022, but it’s still not “write once, ship everywhere.” It’s “write once, test everywhere, submit to four different stores with four different requirements.”

WXT has removed most of the build complexity. The polyfill has removed most of the API namespace pain. Feature detection handles the gaps.

What remains is the work of actually understanding each browser’s constraints and designing around them from the start — not treating Firefox and Safari as an afterthought you’ll “figure out later.”

The developers shipping successfully across all four browsers in 2026 aren’t the ones with the cleverest abstractions. They’re the ones who read the Firefox MDN docs with the same attention they give the Chrome developer docs.

That’s it. That’s the secret.


Building cross-browser extensions and want to maximize your reach in each store? See how your listings compare with ExtensionBooster’s free developer tools.

Share this article

Build better extensions with free tools

Icon generator, MV3 converter, review exporter, and more — no signup needed.

Related Articles