Cross-Browser Development Manifest V3 WebExtensions

Building Cross-Browser Extensions: Chrome, Firefox, Edge, and Beyond

E
Extendable Team
· 15 min read

The modern web browser landscape includes Chrome, Firefox, Edge, Safari, and numerous Chromium-based alternatives. Building extensions that work across all these platforms requires understanding their commonalities and differences. This guide will help you create truly portable browser extensions.

The WebExtensions Standard

The good news: most modern browsers have converged on the WebExtensions API standard. This means the core APIs for tabs, storage, messaging, and content scripts work similarly across browsers.

Compatibility Baseline: Chrome, Firefox, Edge, Opera, and Brave all support WebExtensions. Safari has its own extension system but is increasingly compatible with WebExtensions through the Safari Web Extension converter.

Manifest Differences

The manifest.json file is where most cross-browser differences appear. Here’s how to handle them:

Manifest V3 vs V2

Chrome requires Manifest V3 for new extensions, while Firefox supports both V2 and V3. Edge follows Chrome’s requirements.

// manifest.json for Manifest V3 (Chrome/Edge)
{
  "manifest_version": 3,
  "name": "My Extension",
  "version": "1.0.0",
  "action": {
    "default_popup": "popup.html",
    "default_icon": "icon.png"
  },
  "background": {
    "service_worker": "background.js"
  },
  "permissions": ["storage", "activeTab"],
  "host_permissions": ["<all_urls>"]
}
// manifest.json for Manifest V2 (Firefox fallback)
{
  "manifest_version": 2,
  "name": "My Extension",
  "version": "1.0.0",
  "browser_action": {
    "default_popup": "popup.html",
    "default_icon": "icon.png"
  },
  "background": {
    "scripts": ["background.js"],
    "persistent": false
  },
  "permissions": ["storage", "activeTab", "<all_urls>"]
}

Browser-Specific API Namespaces

Chrome uses chrome.* APIs while Firefox and Edge support both chrome.* and browser.*. Firefox’s browser.* APIs return Promises, making them easier to work with:

// Chrome style (callbacks)
chrome.storage.local.get(['key'], (result) => {
  console.log(result.key);
});

// Firefox style (promises)
const result = await browser.storage.local.get('key');
console.log(result.key);

Creating a Universal Wrapper

Build a wrapper that normalizes the API across browsers:

// browser-polyfill.js
const browserAPI = typeof browser !== 'undefined' ? browser : chrome;

// Promisify Chrome APIs if needed
function promisify(fn) {
  return (...args) => {
    return new Promise((resolve, reject) => {
      fn(...args, (result) => {
        if (chrome.runtime.lastError) {
          reject(chrome.runtime.lastError);
        } else {
          resolve(result);
        }
      });
    });
  };
}

export const storage = {
  get: typeof browser !== 'undefined'
    ? browser.storage.local.get.bind(browser.storage.local)
    : promisify(chrome.storage.local.get.bind(chrome.storage.local)),
  set: typeof browser !== 'undefined'
    ? browser.storage.local.set.bind(browser.storage.local)
    : promisify(chrome.storage.local.set.bind(chrome.storage.local))
};

export const tabs = {
  query: typeof browser !== 'undefined'
    ? browser.tabs.query.bind(browser.tabs)
    : promisify(chrome.tabs.query.bind(chrome.tabs)),
  sendMessage: typeof browser !== 'undefined'
    ? browser.tabs.sendMessage.bind(browser.tabs)
    : promisify(chrome.tabs.sendMessage.bind(chrome.tabs))
};
Pro Tip: Use the webextension-polyfill library instead of building your own. It's well-maintained and handles edge cases.

Handling Service Workers vs Background Scripts

Manifest V3 uses service workers instead of persistent background pages. This has significant implications:

// Service Worker (MV3) - No DOM access, no persistent state
// background.js

// Use chrome.storage instead of variables for state
let cache = null; // This will be lost when service worker restarts!

// Better approach
async function getCache() {
  const result = await chrome.storage.session.get('cache');
  return result.cache || {};
}

async function setCache(data) {
  await chrome.storage.session.set({ cache: data });
}

// Handle service worker lifecycle
chrome.runtime.onStartup.addListener(async () => {
  // Reinitialize state when browser starts
  await setCache({});
});

chrome.runtime.onInstalled.addListener(async () => {
  // Initialize on first install or update
  await setCache({});
});

Content Security Policy Differences

CSP requirements vary between browsers and manifest versions:

// Manifest V3 CSP (stricter)
{
  "content_security_policy": {
    "extension_pages": "script-src 'self'; object-src 'self'"
  }
}

// Manifest V2 CSP
{
  "content_security_policy": "script-src 'self'; object-src 'self'"
}

Build System for Multi-Browser Support

Use a build system to generate browser-specific builds:

// build.js
const fs = require('fs');
const path = require('path');

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

const baseManifest = {
  name: "My Extension",
  version: "1.0.0",
  description: "A cross-browser extension"
};

const browserConfigs = {
  chrome: {
    manifest_version: 3,
    action: { default_popup: "popup.html" },
    background: { service_worker: "background.js" }
  },
  firefox: {
    manifest_version: 2,
    browser_action: { default_popup: "popup.html" },
    background: { scripts: ["background.js"] },
    browser_specific_settings: {
      gecko: { id: "extension@example.com" }
    }
  },
  edge: {
    manifest_version: 3,
    action: { default_popup: "popup.html" },
    background: { service_worker: "background.js" }
  }
};

browsers.forEach(browser => {
  const manifest = { ...baseManifest, ...browserConfigs[browser] };
  const outDir = `dist/${browser}`;

  fs.mkdirSync(outDir, { recursive: true });
  fs.writeFileSync(
    path.join(outDir, 'manifest.json'),
    JSON.stringify(manifest, null, 2)
  );

  // Copy other files...
});

Testing Across Browsers

Automated testing across browsers is essential:

// Using Playwright for cross-browser testing
const { chromium, firefox } = require('playwright');

async function testExtension(browserType, extensionPath) {
  const context = await browserType.launchPersistentContext('', {
    headless: false,
    args: [
      `--disable-extensions-except=${extensionPath}`,
      `--load-extension=${extensionPath}`
    ]
  });

  const page = await context.newPage();
  await page.goto('https://example.com');

  // Test extension functionality
  const extensionPopup = await context.waitForEvent('page');
  await extensionPopup.click('#my-button');

  // Assert results
  const result = await page.evaluate(() => {
    return document.querySelector('#extension-result').textContent;
  });

  console.log(`${browserType.name()} test result:`, result);
  await context.close();
}

// Run tests
testExtension(chromium, './dist/chrome');
testExtension(firefox, './dist/firefox');

Handling Browser-Specific Features

Some features only exist in certain browsers. Use feature detection:

// Feature detection for optional APIs
function hasContainerSupport() {
  return typeof browser !== 'undefined' &&
         typeof browser.contextualIdentities !== 'undefined';
}

function hasSidebarSupport() {
  return typeof browser !== 'undefined' &&
         typeof browser.sidebarAction !== 'undefined';
}

// Use features conditionally
if (hasContainerSupport()) {
  browser.contextualIdentities.query({}).then(containers => {
    // Firefox container tabs feature
  });
}

if (hasSidebarSupport()) {
  browser.sidebarAction.setPanel({ panel: 'sidebar.html' });
}

Publishing to Multiple Stores

Each browser store has different requirements:

StoreReview TimeRequirements
Chrome Web Store1-3 daysMV3, Privacy policy for user data
Firefox Add-ons1-7 daysSource code if minified
Edge Add-ons3-7 daysSame as Chrome (based on Chromium)
Publishing Strategy: Start with Chrome Web Store since it has the largest user base, then port to Firefox and Edge. Use the same extension ID across stores when possible to simplify updates.

Summary

Building cross-browser extensions requires attention to manifest differences, API variations, and browser-specific features. By using polyfills, conditional feature detection, and automated build systems, you can maintain a single codebase that targets all major browsers.

Key strategies:

  • Use the webextension-polyfill library
  • Set up build pipelines for each browser
  • Test on all target browsers before release
  • Handle graceful degradation for browser-specific features
  • Keep up with browser changelog and deprecation notices