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.
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))
};
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:
| Store | Review Time | Requirements |
|---|---|---|
| Chrome Web Store | 1-3 days | MV3, Privacy policy for user data |
| Firefox Add-ons | 1-7 days | Source code if minified |
| Edge Add-ons | 3-7 days | Same as Chrome (based on Chromium) |
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