Skip to main content
Technology & EngineeringBrowser Extension239 lines

Cross-Browser

Cross-browser extension compatibility across Chrome, Firefox, and Safari with API normalization and build strategies

Quick Summary27 lines
You are an expert in cross-browser compatibility for building browser extensions.

## Key Points

- Firefox MV3 uses `"background": { "scripts": [...] }` (persistent event page) instead of `"service_worker"`.
- Firefox requires `browser_specific_settings.gecko.id` for signing and distribution.
- Safari uses Xcode to wrap the extension in a native app container.
- Must be distributed through the Mac App Store or Apple's developer program.
- Some APIs are missing or partially implemented (e.g., `declarativeNetRequest` has limited support).
- Use `browser.runtime.getURL()` consistently; `chrome.*` namespace is not available in Safari.
- Use `webextension-polyfill` to write Promise-based code once and run it across all browsers, rather than maintaining separate callback-based and Promise-based code paths.
- Use feature detection (`typeof chrome.sidePanel !== 'undefined'`) instead of user-agent sniffing to determine available capabilities.
- Maintain a single source tree with a build step that generates browser-specific manifests and includes/excludes platform-specific modules.
- Assuming `chrome.storage` callback-style APIs work identically in Firefox — Firefox's `browser.storage` returns Promises, and mixing paradigms causes subtle bugs; use the polyfill to avoid this.
- Shipping a Chrome extension to Firefox without testing — differences in service worker support, CSP enforcement, and permission prompts can cause silent failures.

## Quick Example

```bash
xcrun safari-web-extension-converter /path/to/extension --project-location /output
```

```bash
npm install webextension-polyfill
```
skilldb get browser-extension-skills/Cross-BrowserFull skill: 239 lines
Paste into your CLAUDE.md or agent config

Cross-Browser Compatibility — Browser Extension Development

You are an expert in cross-browser compatibility for building browser extensions.

Overview

Browser extensions can target Chrome, Firefox, Safari, and other Chromium-based browsers (Edge, Opera, Brave). While the WebExtensions API standard provides a common foundation, each browser has differences in API namespaces, manifest handling, and supported features. A well-structured extension can share the majority of its code across browsers.

Core Concepts

API Namespace Differences

Chrome uses chrome.* APIs, while Firefox supports both browser.* (Promise-based) and chrome.* (callback-based).

// Normalize the API namespace
const api = typeof browser !== 'undefined' ? browser : chrome;

// Firefox's browser.* returns Promises natively
// Chrome's chrome.* historically used callbacks but now also supports Promises in MV3

// Wrapper for environments that may not support Promises
function storageGet(keys) {
  return new Promise((resolve, reject) => {
    const target = typeof browser !== 'undefined' ? browser : chrome;
    const result = target.storage.local.get(keys, (data) => {
      if (chrome.runtime.lastError) {
        reject(chrome.runtime.lastError);
      } else {
        resolve(data);
      }
    });
    // Firefox returns a Promise directly
    if (result instanceof Promise) {
      result.then(resolve).catch(reject);
    }
  });
}

Manifest Differences

Firefox uses some different manifest keys:

// Chrome manifest.json
{
  "manifest_version": 3,
  "background": {
    "service_worker": "background.js"
  },
  "action": { "default_popup": "popup.html" }
}

// Firefox manifest.json
{
  "manifest_version": 3,
  "background": {
    "scripts": ["background.js"]
  },
  "action": { "default_popup": "popup.html" },
  "browser_specific_settings": {
    "gecko": {
      "id": "addon@example.com",
      "strict_min_version": "109.0"
    }
  }
}

Key differences:

  • Firefox MV3 uses "background": { "scripts": [...] } (persistent event page) instead of "service_worker".
  • Firefox requires browser_specific_settings.gecko.id for signing and distribution.
  • Safari uses Xcode to wrap the extension in a native app container.

Safari Considerations

Safari extensions are packaged inside a macOS/iOS app using Xcode. The safari-web-extension-converter tool converts a WebExtension into an Xcode project:

xcrun safari-web-extension-converter /path/to/extension --project-location /output

Safari-specific notes:

  • Must be distributed through the Mac App Store or Apple's developer program.
  • Some APIs are missing or partially implemented (e.g., declarativeNetRequest has limited support).
  • Use browser.runtime.getURL() consistently; chrome.* namespace is not available in Safari.

Implementation Patterns

Build Pipeline for Multiple Browsers

Use a build tool to produce browser-specific bundles:

// build.js
import { readFileSync, writeFileSync, cpSync, mkdirSync } from 'fs';

const browsers = ['chrome', 'firefox', 'safari'];

function buildForBrowser(target) {
  const outDir = `dist/${target}`;
  mkdirSync(outDir, { recursive: true });

  // Copy shared source files
  cpSync('src/shared', outDir, { recursive: true });
  cpSync(`src/${target}`, outDir, { recursive: true });

  // Generate manifest
  const base = JSON.parse(readFileSync('src/manifest.base.json', 'utf-8'));
  const overrides = JSON.parse(readFileSync(`src/${target}/manifest.overrides.json`, 'utf-8'));
  const manifest = { ...base, ...overrides };

  if (target === 'firefox') {
    // Firefox uses background scripts, not service_worker
    manifest.background = { scripts: ['background.js'] };
    manifest.browser_specific_settings = {
      gecko: { id: 'addon@example.com', strict_min_version: '109.0' }
    };
  }

  writeFileSync(`${outDir}/manifest.json`, JSON.stringify(manifest, null, 2));
}

browsers.forEach(buildForBrowser);

Feature Detection

Prefer feature detection over browser sniffing:

// Check if an API exists before using it
function supportsDeclarativeNetRequest() {
  return typeof chrome !== 'undefined' &&
    typeof chrome.declarativeNetRequest !== 'undefined';
}

function supportsSidePanel() {
  return typeof chrome !== 'undefined' &&
    typeof chrome.sidePanel !== 'undefined';
}

// Use the feature if available, fallback otherwise
if (supportsDeclarativeNetRequest()) {
  chrome.declarativeNetRequest.updateDynamicRules({ addRules, removeRuleIds });
} else {
  // Fallback for browsers without DNR
  chrome.webRequest.onBeforeRequest.addListener(
    blockHandler,
    { urls: blockList },
    ['blocking']
  );
}

Polyfill Layer

Use the webextension-polyfill library to normalize the API across browsers:

npm install webextension-polyfill
// background.js
import browser from 'webextension-polyfill';

// Now use browser.* everywhere with Promise-based APIs
const data = await browser.storage.local.get('settings');
const tabs = await browser.tabs.query({ active: true, currentWindow: true });
await browser.tabs.sendMessage(tabs[0].id, { type: 'PING' });

Platform-Specific UI Adjustments

function getPlatformInfo() {
  if (typeof browser !== 'undefined' && browser.runtime.getBrowserInfo) {
    return browser.runtime.getBrowserInfo().then((info) => info.name.toLowerCase());
  }
  const ua = navigator.userAgent;
  if (ua.includes('Firefox')) return Promise.resolve('firefox');
  if (ua.includes('Safari') && !ua.includes('Chrome')) return Promise.resolve('safari');
  return Promise.resolve('chrome');
}

async function adjustUI() {
  const platform = await getPlatformInfo();

  if (platform === 'firefox') {
    // Firefox popup sizing works differently
    document.body.style.width = '400px';
  }

  if (platform === 'safari') {
    // Hide features not supported on Safari
    document.getElementById('dnr-section')?.remove();
  }
}

Best Practices

  • Use webextension-polyfill to write Promise-based code once and run it across all browsers, rather than maintaining separate callback-based and Promise-based code paths.
  • Use feature detection (typeof chrome.sidePanel !== 'undefined') instead of user-agent sniffing to determine available capabilities.
  • Maintain a single source tree with a build step that generates browser-specific manifests and includes/excludes platform-specific modules.

Core Philosophy

Cross-browser compatibility is not an afterthought to bolt on at the end — it is an architectural decision that shapes how you write every line of extension code. Start with the common WebExtensions API surface and treat browser-specific features as progressive enhancements. The goal is a single codebase with minimal platform-specific branches, not three separate extensions that happen to share some files.

Feature detection should always win over browser sniffing. Checking whether an API exists (typeof chrome.sidePanel !== 'undefined') is robust against new browser versions and Chromium forks you have never heard of. User-agent strings lie, browser detection functions break, but the presence or absence of an API is a ground truth that works everywhere.

Invest in a build pipeline early. Generating browser-specific manifests, including or excluding platform modules, and producing correctly structured ZIPs for each store is tedious work that compounds over time. Automate it once with a simple build script so that every developer and CI run produces consistent, correct outputs for all target browsers.

Anti-Patterns

  • Browser sniffing with user-agent strings — parsing navigator.userAgent to determine capabilities is fragile, unreliable across Chromium forks, and breaks when browsers change their UA format; use feature detection instead.

  • Maintaining separate codebases per browser — forking the entire codebase for Chrome, Firefox, and Safari leads to divergent behavior, duplicated bugs, and triple the maintenance burden; share code and isolate differences behind a thin abstraction layer.

  • Assuming MV3 parity across browsers — Firefox's MV3 implementation uses background scripts instead of service workers, and Safari's MV3 support differs in significant ways; test each browser's actual behavior rather than assuming Chrome's documentation applies everywhere.

  • Hardcoding the chrome.* namespace — calling chrome.storage directly throughout the codebase prevents clean Firefox compatibility; use the webextension-polyfill or a namespace abstraction to write once and run everywhere.

  • Ignoring Safari entirely — Safari's extension ecosystem is smaller but growing, and its WebKit-based webview has unique behaviors around CSP, permissions, and API availability that only surface through testing.

Common Pitfalls

  • Assuming chrome.storage callback-style APIs work identically in Firefox — Firefox's browser.storage returns Promises, and mixing paradigms causes subtle bugs; use the polyfill to avoid this.
  • Shipping a Chrome extension to Firefox without testing — differences in service worker support, CSP enforcement, and permission prompts can cause silent failures.

Install this skill directly: skilldb add browser-extension-skills

Get CLI access →