Chrome Extension Cross-Browser Development — Developer Guide

13 min read

Cross-Browser Extension Development

Building extensions that work across Chrome, Firefox, Edge, and Safari requires understanding each browser’s WebExtension implementation, API availability, and submission requirements. This guide covers the essential patterns and techniques for achieving true cross-browser compatibility.

Browser Comparison Table

Feature Chrome Firefox Edge Safari
Manifest V3 ✅ Full ✅ Full ✅ Full ✅ Full
Service Workers ✅ (background scripts) ✅ (since Safari 16.4)
chrome.* namespace ✅ Native ✅ Via Polyfill ✅ Native ✅ Native
browser.* namespace ✅ Native (since Chrome 146) ✅ Native ✅ Alias ✅ Alias
Promise-based APIs ✅ Partial
sidePanel API
declarativeNetRequest
offscreenDocument
tabGroups
side_panel in manifest

Namespace Differences

WebExtension Polyfill

The webextension-polyfill normalizes API differences across browsers by providing a Promise-based browser.* interface that works everywhere.

Installation

npm install webextension-polyfill

Usage

// Instead of chrome.* with callbacks:
chrome.runtime.sendMessage('ext-id', { action: 'doThing' }, (response) => {
  console.log(response);
});

// Use browser.* with Promises:
import browser from 'webextension-polyfill';

const response = await browser.runtime.sendMessage('ext-id', { 
  action: 'doThing' 
});
console.log(response);

Setup in Background Script

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

// Automatically normalizes chrome.* to browser.*

Setup in Content Script

// content.js
import browser from 'webextension-polyfill';

// Use browser.runtime instead of chrome.runtime

Note: The polyfill doesn’t add missing APIs—it only normalizes the interface (Promises vs callbacks, browser.* vs chrome.*).

Feature Detection Pattern

Always check if an API exists before using it. This prevents errors when running on browsers that don’t support certain features.

Basic Detection

// Check for sidePanel API (Chrome/Edge only)
if (chrome.sidePanel) {
  chrome.sidePanel.setOptions({ path: 'sidepanel.html' });
} else {
  // Fallback: open a new tab as sidebar alternative
  console.log('sidePanel not supported, using tab fallback');
}

// Check for declarativeNetRequest
if (chrome.declarativeNetRequest) {
  chrome.declarativeNetRequest.updateDynamicRules({});
} else if (chrome.webRequest) {
  // Fallback for Firefox
  chrome.webRequest.onBeforeRequest.addListener(...);
}

// Check for offscreenDocument
if (chrome.offscreen) {
  await chrome.offscreen.createDocument({ ... });
} else {
  // Fallback: use a popup or dedicated tab
}

Advanced Detection with Defaults

const BrowserFeatures = {
  hasSidePanel: !!chrome.sidePanel,
  hasOffscreen: !!chrome.offscreen,
  has DNR: !!chrome.declarativeNetRequest,
  hasTabGroups: !!chrome.tabs.group,
  
  // Get the correct sidebar implementation
  getSidebarPath() {
    if (this.hasSidePanel) return 'sidepanel.html';
    // Firefox could use sidebar_action in manifest
    return 'sidebar.html';
  }
};

Build Configuration

Use a build tool to generate browser-specific builds from a shared codebase.

Vite with Multiple Targets

// vite.config.js
import { defineConfig } from 'vite';
import chromeExtensionReloader from 'vite-plugin-chrome-extension-reloader';

export default defineConfig({
  build: {
    rollupOptions: {
      input: {
        background: 'src/background.js',
        popup: 'src/popup.html',
        content: 'src/content.js'
      }
    }
  },
  plugins: [chromeExtensionReloader()]
});

Webpack Multi-Build

// webpack.config.js
module.exports = [
  // Chrome/Edge build
  {
    target: 'web',
    entry: './src/background.js',
    output: { path: './dist/chrome/background.js' },
    // ... Chrome-specific config
  },
  // Firefox build
  {
    target: 'web',
    entry: './src/background.js',
    output: { path: './dist/firefox/background.js' },
    // ... Firefox-specific config
  }
];

Separate Manifests per Browser

src/
  manifest.json          # Base manifest
  manifests/
    chrome-manifest.json
    firefox-manifest.json
    safari-manifest.json

Use a build script to merge the base manifest with browser-specific overrides:

// scripts/build-manifest.js
const base = require('../src/manifest.json');
const browser = process.env.BROWSER;
const override = require(`../src/manifests/${browser}-manifest.json`);

const merged = { ...base, ...override };
console.log(JSON.stringify(merged, null, 2));

Key Differences

Service Worker vs Background Scripts

// Detect current environment
const isServiceWorker = !('onConnect' in chrome.runtime);

// Handle differently based on environment
if (isServiceWorker) {
  // Use addListener for persistent events
  chrome.runtime.onMessage.addListener(handleMessage);
} else {
  // Direct event registration works in event pages
  chrome.runtime.onMessage.addListener(handleMessage);
}

Host Permissions Placement

{
  "permissions": [
    "storage",
    "tabs"
  ],
  "host_permissions": [
    "https://*.example.com/*"
  ]
}

Firefox and Safari handle host permissions differently—always test permission-related functionality.

Feature Chrome/Edge SidePanel Firefox Sidebar
Manifest key side_panel sidebar_action
API chrome.sidePanel browser.sidebarAction
Default path In manifest In manifest
// Universal sidebar handler
function openSidebar() {
  if (chrome.sidePanel) {
    chrome.sidePanel.setOptions({ path: 'sidebar.html' });
    chrome.sidePanel.open();
  } else if (browser.sidebarAction) {
    browser.sidebarAction.open();
  }
}

DNR vs webRequest

// Universal request blocking
if (chrome.declarativeNetRequest) {
  // MV3 approach
  chrome.declarativeNetRequest.updateDynamicRules({
    addRules: [{ 
      id: 1, 
      priority: 1, 
      action: { type: 'block' }, 
      condition: { urlFilter: '*.ads.*' } 
    }]
  });
} else if (chrome.webRequest) {
  // MV2 fallback (Firefox)
  chrome.webRequest.onBeforeRequest.addListener(
    () => ({ cancel: true }),
    { urls: ['*://*.ads.com/*'] },
    ['blocking']
  );
}

Store Submission

Chrome Web Store (CWS)

  1. Zip your extension (dist/ folder, not source)
  2. Upload via Developer Dashboard
  3. Pay one-time $5 developer fee
  4. Review typically takes hours to days
  5. Publish instantly after approval

Firefox Add-ons (AMO)

  1. Sign via Mozilla Add-ons Developer Hub
  2. No listing fee
  3. Review: automated + manual (varies)
  4. Can auto-update via Update URL

Edge Add-ons

  1. Use Partner Center Dashboard
  2. Free developer registration
  3. Review takes 3-5 business days typically
  4. Must meet Microsoft Store policies

Safari App Store

  1. Requires Apple Developer Program ($99/year)
  2. Must use Xcode to build and package
  3. Safari Web Extensions require macOS + Xcode
  4. Submit through App Store Connect
  5. Review takes 1-2 weeks typically

Review Process Differences

Store Fee Review Time Auto-Update Notes
Chrome Web Store $5 (one-time) Hours-Days Strict policy enforcement
Firefox AMO Free Days-Weeks Less strict, but quality matters
Edge Add-ons Free 3-5 business days Microsoft Store policies
Safari $99/year 1-2 weeks Requires Xcode packaging

Testing Across Browsers

Separate Browser Profiles

Create dedicated profiles for development to avoid conflicts:

# Chrome
chrome --remote-debugging-port=9222 --user-data-dir=./chrome-dev-profile

# Firefox
firefox -P firefox-dev-profile -no-remote

# Edge
msedge --remote-debugging-port=9222 --user-data-dir=./edge-dev-profile

Playwright for Automation

// test/extension.spec.js
import { test, expect } from '@playwright/test';

test('extension popup works', async ({ page }) => {
  // Load unpacked extension
  const context = await browser.newContext();
  await context.grantPermissions(['clipboard-read']);
  
  // Test extension functionality
  await page.goto('popup.html');
  await page.click('#action-button');
  // ... assertions
});

Manual Testing Checklist

Abstraction Layer Pattern

Create a browser-api.ts wrapper to centralize feature detection and API normalization:

// src/utils/browser-api.ts

interface BrowserAPI {
  runtime: typeof chrome.runtime;
  tabs: typeof chrome.tabs;
  sidePanel?: typeof chrome.sidePanel;
  storage: typeof chrome.storage;
}

function getBrowserAPI(): BrowserAPI {
  const api: BrowserAPI = {
    runtime: chrome.runtime,
    tabs: chrome.tabs,
    storage: chrome.storage,
  };
  
  // Feature detection
  if (chrome.sidePanel) {
    api.sidePanel = chrome.sidePanel;
  }
  
  return api;
}

export const browserAPI = getBrowserAPI();

// Usage
export async function openSidePanel(path: string): Promise<void> {
  if (browserAPI.sidePanel) {
    await browserAPI.sidePanel.setOptions({ path });
    await browserAPI.sidePanel.open();
  } else {
    // Fallback for Firefox
    console.warn('Side panel not supported');
  }
}

Common Mistakes

❌ Assuming chrome.* Works Everywhere

// BAD: Will fail on Firefox
chrome.runtime.sendMessage({ data: 'hello' });

// GOOD: Use the polyfill or feature detection
import browser from 'webextension-polyfill';
await browser.runtime.sendMessage({ data: 'hello' });

❌ Chrome-Only APIs Without Detection

// BAD: Will throw error on Firefox
chrome.sidePanel.setOptions({ path: 'panel.html' });

// GOOD: Always check first
if (chrome.sidePanel) {
  chrome.sidePanel.setOptions({ path: 'panel.html' });
}

❌ Safari Xcode Requirement

Safari extensions require:

Don’t target Safari without accounting for the build complexity.

❌ Ignoring Manifest Differences

{
  "browser_specific_settings": {
    "gecko": {
      "id": "your-extension@domain.com",
      "strict_min_version": "109.0"
    },
    "safari": {
      "min_version": "15.4"
    }
  }
}

Works With Our Packages

The following packages from @theluckystrike are designed for cross-browser compatibility:

@theluckystrike/webext-messaging

A Promise-based messaging library that works across all browsers:

import { Messaging } from '@theluckystrike/webext-messaging';

// Simple message passing between context
const msg = new Messaging();

await msg.sendToBackground({ action: 'getData' });
await msg.sendToContent(tabId, { action: 'updateUI' });

@theluckystrike/webext-storage

Promise-based storage abstraction with cross-browser support:

import { Storage } from '@theluckystrike/webext-storage';

const storage = new Storage();

// Automatic Promise handling, works everywhere
await storage.set('settings', { theme: 'dark' });
const settings = await storage.get('settings');

Both packages handle the Promise/callback normalization and provide consistent APIs across Chrome, Firefox, Edge, and Safari.

Part of the Chrome Extension Guide by theluckystrike. Built at zovo.one.