Cross-Browser13 min read

Cross-Browser Extension Development in 2026

Build extensions that work everywhere: Chrome, Firefox, Edge, and Safari. Compare WebExtensions APIs, polyfill strategies, and testing workflows for 2026.

C
CWS Kit Team
Share
🌐

Cross-Browser Extension Development

One codebase. Four browsers. Zero compromises.

The dream of "write once, run everywhere" has always been aspirational for browser extensions. In 2026, we are closer than ever — but the gap between aspiration and reality is littered with namespace differences, API quirks, and platform-specific gotchas that can burn hours if you are not prepared.

This guide breaks down exactly where Chrome, Firefox, Edge, and Safari converge, where they diverge, and how to build a single codebase that handles all of them gracefully.

The State of WebExtensions in 2026#

The WebExtensions API was supposed to unify extension development across browsers. And it has — mostly. All four major browsers now support the core WebExtensions standard, but each has its own dialect, its own edge cases, and its own timeline for adopting new features.

FeatureFeatureChromeFirefoxEdgeSafari
Manifest VersionMV3 onlyMV3 + MV2MV3 onlyMV3 only
Service Workers✓ Full✓ Full✓ Full✓ Limited
declarativeNetRequest✓ 300K rules✓ 150K rules✓ 300K rules✓ 50K rules
Sidebar API✗ (Side Panel)✓ Native✗ (Side Panel)
Namespacechrome.*browser.*chrome.*browser.*
Promise Support✓ Native✓ Native✓ Native✓ Native
Background Persistence✗ 30s idle✓ Persistent option✗ 30s idle✗ 30s idle
userScripts API
Store Review Time1-3 days1-2 days1-3 days5-14 days

Firefox remains the most developer-friendly environment. It still supports Manifest V2 for existing extensions, offers persistent background pages as an option, and the browser.* namespace returns promises natively without wrappers. Safari, by contrast, is the most constrained — lower rule limits for declarativeNetRequest, no userScripts API, and review times that can stretch past two weeks.

Edge is functionally identical to Chrome for extension development since it runs on Chromium. The differences are cosmetic: different store, different review process, different user base. But those "cosmetic" differences matter when you are shipping.

The Namespace Problem (And How to Solve It)#

The most immediate cross-browser issue you will hit is the namespace split. Chrome and Edge use chrome.*. Firefox and Safari use browser.*. The APIs are mostly identical underneath, but your code needs to know which object to call.

```typescript // Don't do this — breaks in Firefox chrome.tabs.query({active: true}, (tabs) => { console.log(tabs[0].url); }); ``` This works in Chrome and Edge but throws a ReferenceError in Firefox when loaded as a temporary add-on without the chrome polyfill.

The polyfill approach wins for almost every project. The webextension-polyfill package is around 20KB unminified, well-tested, and handles dozens of subtle differences you would otherwise discover the hard way — like Firefox rejecting callback-style calls on APIs that only support promises.

Manifest Differences That Bite#

Your manifest.json cannot be identical across all browsers. Some fields are browser-specific, some have different value constraints, and Safari requires an Xcode project wrapper on top of everything.

Here is a practical strategy: maintain a base manifest and use a build script to produce browser-specific variants.

// build-manifests.ts
import baseManifest from './manifest.base.json';
 
type BrowserTarget = 'chrome' | 'firefox' | 'edge' | 'safari';
 
function buildManifest(target: BrowserTarget) {
  const manifest = structuredClone(baseManifest);
 
  switch (target) {
    case 'firefox':
      // Firefox supports browser_specific_settings
      manifest.browser_specific_settings = {
        gecko: {
          id: "your-extension@example.com",
          strict_min_version: "128.0"
        }
      };
      // Firefox uses sidebar_action, not side_panel
      if (manifest.side_panel) {
        manifest.sidebar_action = {
          default_panel: manifest.side_panel.default_path
        };
        delete manifest.side_panel;
      }
      break;
 
    case 'safari':
      // Safari has lower DNR rule limits
      // Remove unsupported APIs from permissions
      manifest.permissions = manifest.permissions.filter(
        (p: string) => !['userScripts'].includes(p)
      );
      break;
 
    case 'edge':
      // Edge is Chrome-compatible, but add Edge-specific metadata
      manifest.update_url = "https://edge.microsoft.com/extensionwebstorebase/v1/crx";
      break;
  }
 
  return manifest;
}
Pros
  • Single source of truth for extension metadata
  • Build-time validation catches browser-specific issues early
  • Easy to add or remove browser targets
  • Can conditionally include/exclude features per browser
Cons
  • Adds build complexity — you need a manifest generation step
  • Must track which manifest fields each browser supports
  • Safari still requires Xcode wrapper on top of the generated manifest
  • Testing requires building and loading for each target separately

Content Script Injection: Where Browsers Diverge#

Content scripts are where cross-browser differences get genuinely painful. The injection model, timing, and available APIs all vary in subtle ways.

Building a Cross-Browser Build Pipeline#

The most effective cross-browser setup in 2026 uses a single TypeScript codebase with conditional compilation. Here is a build architecture that scales:

// vite.config.ts
import { defineConfig } from 'vite';
import { resolve } from 'path';
 
const target = process.env.BROWSER_TARGET || 'chrome';
 
export default defineConfig({
  define: {
    __BROWSER__: JSON.stringify(target),
    __IS_FIREFOX__: target === 'firefox',
    __IS_SAFARI__: target === 'safari',
    __IS_CHROMIUM__: target === 'chrome' || target === 'edge',
  },
  build: {
    outDir: `dist/${target}`,
    rollupOptions: {
      input: {
        background: resolve(__dirname, 'src/background/index.ts'),
        content: resolve(__dirname, 'src/content/index.ts'),
        popup: resolve(__dirname, 'src/popup/index.html'),
      },
    },
  },
});

Then in your source code, use compile-time branching:

// src/features/sidebar.ts
export function initSidebar() {
  if (__IS_FIREFOX__) {
    // Firefox uses sidebar_action API
    browser.sidebarAction.setPanel({ panel: 'sidebar.html' });
  } else if (__IS_CHROMIUM__) {
    // Chrome/Edge use side_panel API
    chrome.sidePanel.setOptions({ path: 'sidebar.html', enabled: true });
  } else {
    // Safari — no native sidebar, fall back to popup
    console.log('Sidebar not supported, using popup fallback');
  }
}

The __IS_FIREFOX__ constants get replaced at build time, so dead code is eliminated from each browser's bundle. Your Chrome build never ships Firefox-specific code, and vice versa.

Testing Across Browsers#

Automated testing across four browsers is non-negotiable for any serious cross-browser extension. Manual testing simply does not scale.

1

Unit Test Core Logic

Test business logic in isolation with Vitest or Jest. No browser APIs needed — mock the browser namespace.

2

Integration Test with Puppeteer/Playwright

Load your extension in headless Chrome and Firefox. Test popup rendering, content script injection, and background communication.

3

Safari Manual QA

Safari's WebDriver support for extensions is still immature. Build the Xcode project, load in Safari, and test critical flows manually.

4

Cross-Browser CI Matrix

Run Chrome and Firefox tests in CI on every PR. Use GitHub Actions matrix strategy to parallelize. Safari tests run on macOS runners weekly.

Here is a practical Playwright configuration for extension testing:

// playwright.config.ts
import { defineConfig } from '@playwright/test';
 
export default defineConfig({
  projects: [
    {
      name: 'chrome',
      use: {
        browserName: 'chromium',
        launchOptions: {
          args: [
            `--disable-extensions-except=./dist/chrome`,
            `--load-extension=./dist/chrome`,
          ],
        },
      },
    },
    {
      name: 'firefox',
      use: {
        browserName: 'firefox',
        // Firefox extension loading requires a different approach
        // Use web-ext API to install temporarily
      },
    },
  ],
});

Storage API: Mostly Unified, Partially Tricky#

The storage API is one of the best-aligned APIs across browsers — but "mostly the same" is exactly where bugs hide.

// src/storage/cross-browser-storage.ts
import browser from 'webextension-polyfill';
 
interface StorageResult<T> {
  data: T | null;
  error: string | null;
}
 
export async function safeSet<T>(
  key: string,
  value: T,
  area: 'local' | 'sync' = 'local'
): Promise<StorageResult<T>> {
  try {
    await browser.storage[area].set({ [key]: value });
    return { data: value, error: null };
  } catch (err) {
    if ((err as Error).message.includes('QUOTA_BYTES')) {
      // Handle quota exceeded — fall back to local if sync fails
      if (area === 'sync') {
        return safeSet(key, value, 'local');
      }
      return { data: null, error: 'Storage quota exceeded' };
    }
    return { data: null, error: (err as Error).message };
  }
}

Messaging Between Contexts#

Message passing works reliably across all browsers when you use webextension-polyfill. The one major difference: Chrome limits runtime.sendMessage payload size to approximately 64MB (serialized), while Firefox is more lenient. In practice, if you are sending more than a few KB in a single message, you should chunk the data regardless of browser.

Do
  • Use webextension-polyfill for all browser API calls
  • Test on real browser builds — emulators miss extension-specific bugs
  • Handle API absence gracefully with feature detection
  • Keep permissions minimal — every browser's review team scrutinizes broad permissions
  • Use compile-time flags to eliminate dead code per browser
  • Maintain browser-specific manifest generation in your build pipeline
Avoid
  • Assume chrome.* and browser.* are interchangeable without a polyfill
  • Ship the same manifest.json to every browser store
  • Test only on Chrome and assume it works on Firefox
  • Use browser-specific APIs without a fallback path
  • Ignore Safari — it represents 10-15% of macOS extension users
  • Rely on persistent background state — only Firefox offers that option

Polyfill Strategies: Pick Your Level#

Not every extension needs the same level of cross-browser support. Your polyfill strategy should match your ambition.

If you are only targeting Chromium browsers, you need zero polyfills. Chrome and Edge share the same engine and the same extension APIs. Use `chrome.*` directly. Your manifest works on both stores without modification (except for `update_url`). **When to choose this:** Your extension relies heavily on Chrome-specific APIs like Side Panel, or your audience is 95%+ Chrome/Edge users.

Performance Considerations#

Each browser's extension runtime has different performance characteristics. Chrome's service worker startup time is roughly 50-100ms. Firefox can be faster for extensions using persistent background pages. Safari's service worker startup can spike to 200-300ms on first wake.

Average Service Worker Startup Time (ms)

Chrome75Firefox (persistent)10Firefox (event-based)60Edge80Safari220

If your extension is latency-sensitive — intercepting requests, modifying page content on load — these differences matter. Architect your critical path to minimize service worker dependencies. Use declarativeNetRequest for request modification (it runs in the browser's native layer, not in your JavaScript), and use content scripts for page modifications that need to happen before the user sees the page.

Publishing to Multiple Stores#

Each store has its own submission process, review criteria, and timeline. Plan for this.

  1. 🟢

    Chrome Web Store

    Submit via developer.chrome.com. Review takes 1-3 business days for updates, potentially longer for new extensions. $5 one-time developer fee. Supports automated publishing via Chrome Web Store API.

  2. 🦊

    Firefox Add-ons (AMO)

    Submit via addons.mozilla.org. Fastest reviews — often within 24 hours. Free. Self-hosted distribution also available if you want to bypass the store.

  3. 🔵

    Edge Add-ons

    Submit via partner.microsoft.com. Reviews take 1-3 days. Free. Can import directly from Chrome Web Store with minimal changes.

  4. 🍎

    Safari Extensions

    Submit via App Store Connect as part of a macOS/iOS app wrapper. Requires Apple Developer membership ($99/year). Reviews take 5-14 days. Most restrictive requirements.

The Cross-Browser Bottom Line

Cross-browser extension development in 2026 is genuinely feasible with a well-structured build pipeline, webextension-polyfill, and compile-time feature flags. The WebExtensions standard covers 80% of what most extensions need. The remaining 20% — sidebars, advanced DNR, Safari quirks — requires targeted abstractions. Start with Chrome + Firefox, add Edge for free (it is Chromium), and add Safari only when your user base justifies the extra investment.

Test Your Knowledge#

Knowledge Check

1. Which browser namespace returns promises natively without polyfills?

2. What is the maximum declarativeNetRequest rule limit in Safari?

3. Which browser still supports Manifest V2 extensions in 2026?

4. What is the recommended approach for cross-browser API compatibility?

Building cross-browser extensions is no longer the Herculean task it was five years ago. The tooling has matured, the standards have converged, and the remaining differences are well-documented. The extensions that succeed across all browsers in 2026 are the ones that invest in a solid build pipeline early and treat cross-browser support as an architecture decision, not an afterthought.

For more on building robust extensions, check out our Complete Guide to Manifest V3 and Content Scripts Patterns and Pitfalls.

Continue reading

Related articles

View all posts