Cross-Browser API Compatibility
Chrome vs Firefox vs Safari Manifest V3 API compatibility reference — namespace differences, Promise vs callback, storage quotas, declarativeNetRequest, and the webextension-polyfill.
Chrome vs Firefox vs Safari Manifest V3 API compatibility reference — namespace differences, Promise vs callback, storage quotas, declarativeNetRequest, and the webextension-polyfill.
Writing a Manifest V3 extension that runs on Chrome, Firefox, and Safari is achievable — but the three browsers disagree on namespace conventions, Promise support, background context declarations, and quota limits in ways that are not obvious from reading any single vendor’s documentation. Ignoring these differences produces extensions that silently break on non-Chrome browsers: storage writes that reject, background scripts that never register, or network rules that pass review on one platform and fail on another. This guide is part of Core APIs & Cross-Browser Data Management.
The fastest path to a working cross-browser extension is to adopt the webextension-polyfill early and use browser.* everywhere, but even the polyfill cannot paper over behavioural differences in quota limits, missing APIs, and background-context declarations. This reference documents the real divergence for each API domain so you can plan, not discover, the gaps.
Before targeting multiple browsers:
chrome.* everywhere and add the webextension-polyfill shim, or use browser.* natively (works on Firefox, requires the polyfill on Chrome). Mixing both without a strategy produces unmaintainable code.registerContentScripts, Safari 17 for world: "MAIN", Safari 16.4 for removeCSS. Features below these versions need workarounds.background.scripts as a fallback where Safari requires background.service_worker. Chrome ignores background.scripts entirely in MV3.The first practical difference a cross-browser extension hits is the API namespace. Chrome historically used chrome.* with callbacks; Firefox chose browser.* with native Promises. In MV3:
chrome.* namespace; all APIs return Promises in MV3 (callbacks still accepted for backwards compatibility).browser.* namespace natively; chrome.* is aliased to browser.* without Promises, so avoid mixing — use browser.* consistently.browser.* (with Promises) and chrome.* (without Promises) via WebKit’s shim. Always use browser.* or the polyfill on Safari to get Promises.1// Portable namespace resolution — import once at the app root
2const ext = typeof browser !== "undefined"
3 ? browser
4 : chrome as unknown as typeof browser;
5
6export default ext;
Execution context: Module evaluated in the service worker or any extension page. Assigning once and importing the ext reference everywhere means you swap the underlying namespace in one place. The webextension-polyfill package does this and more — use it over a hand-rolled shim in production.
| API / Feature | Chrome / Edge | Firefox | Safari |
|---|---|---|---|
storage.local | Full; 10 MB cap | Full; 10 MB cap | Full; 5 MB cap |
storage.sync | Full; 100 KB / 8 KB per item | Full; 100 KB / 8 KB per item (syncs via Firefox account) | Partial; backed by iCloud, stricter throttling |
storage.session | Chrome 102+ | Firefox 115+ | Safari 17+ |
storage.onChanged | Full | Full | Full; may coalesce rapid changes |
scripting.executeScript | Full | Full (Firefox 102+) | Full (Safari 16+) |
scripting.executeScript — world: "MAIN" | Full | Firefox 102+ | Safari 17+ |
scripting.insertCSS | Full | Full (Firefox 102+) | Full (Safari 16+) |
scripting.removeCSS | Full | Firefox 102+ | Safari 16.4+ |
scripting.registerContentScripts | Full | Firefox 112+ | Safari 17+ |
declarativeNetRequest (static rules) | Full | Firefox 113+ | Safari 15.4+ (limited) |
declarativeNetRequest (dynamic rules) | Full; 5 000 rules | Firefox 113+; 5 000 rules | Safari 15.4+; 150 rules max |
declarativeNetRequest.updateSessionRules | Chrome 111+ | Firefox 113+ | Not supported |
declarativeNetRequest — modifyHeaders | Full | Firefox 113+ | Not supported |
webRequest (read-only) | Deprecated in MV3; requires declarativeNetRequest | Supported in MV3 (Firefox keeps it) | Not supported |
background.service_worker | Required in MV3 | Supported from Firefox 121 | Required in MV3 |
background.scripts (event page) | Not supported in MV3 | Accepted as fallback (Firefox 113–120 compatibility) | Not supported in MV3 |
background.persistent | Removed in MV3 | Ignored | Ignored |
action (unified) | Full | Full | Full |
action.openPopup() | Chrome 99+ | Firefox 118+ | Not supported |
runtime.sendMessage | Full | Full (Promises) | Full |
tabs.query | Full | Full | Full |
tabs.executeScript | Removed in MV3 | Still works in MV2 mode | Removed in MV3 |
offscreen documents | Chrome 109+ | Not supported | Not supported |
| Promise-based APIs | Full (MV3) | Full (native) | Full (via browser.*) |
chrome.* callback style | Supported | Aliased (no Promises) | Partial shim |
All three browsers honour the same quota constants (QUOTA_BYTES, QUOTA_BYTES_PER_ITEM, MAX_ITEMS) on paper, but the behaviour under load differs:
MAX_WRITE_OPERATIONS_PER_MINUTE (~120) is enforced; exceeding it rejects with a quota error that names the constant.storage.sync as local-only storage with the same quota constants.storage.sync writes under real iCloud network conditions.For storage patterns and error handling, see Chrome Storage API & Sync.
The gap between Chrome and Firefox/Safari on declarativeNetRequest is the largest practical compatibility challenge for ad-blocking or privacy extensions:
updateSessionRules, modifyHeaders, redirect with regexSubstitution, and 30 000 static rules (5 000 dynamic). The Chrome Web Store requires declarativeNetRequest for any network-modifying extension.declarativeNetRequest in Firefox 113. Supports modifyHeaders, updateSessionRules, and dynamic rules. Firefox still supports webRequest in MV3 extensions — useful for migration, but do not rely on it for new extensions. See migrating webRequest to declarativeNetRequest for the step-by-step path.declarativeNetRequest in Safari 15.4. Dynamic rule support has a hard cap of 150 rules (vs Chrome’s 5 000). Session rules are not supported. modifyHeaders is not supported. If your extension modifies request headers, Safari support requires a different code path or a fallback to webRequest (which Safari also does not support in MV3, making header modification practically unsupported on Safari MV3 today). 1// Feature-detect session rules before calling updateSessionRules
2async function setSessionRule(rule: chrome.declarativeNetRequest.Rule): Promise<void> {
3 if (!chrome.declarativeNetRequest.updateSessionRules) {
4 console.warn("Session rules not supported in this browser");
5 return;
6 }
7 await chrome.declarativeNetRequest.updateSessionRules({
8 addRules: [rule],
9 removeRuleIds: [],
10 });
11}
Execution context: Runs in the service worker. Feature-detecting on the API object itself (not user-agent sniffing) is the correct pattern — it handles future browser updates automatically. For a deeper look at rule structure and debugging, see declarativeNetRequest rules.
This is the most subtle cross-browser difference and the one most likely to prevent your extension from loading at all on non-Chrome browsers:
1{
2 "manifest_version": 3,
3 "background": {
4 // Chrome and Safari MV3: only this key is recognised
5 "service_worker": "background.js",
6 // Firefox 113-120: falls back to scripts when service_worker is absent
7 // Firefox 121+: accepts service_worker
8 "scripts": ["background.js"], // ignored by Chrome/Safari
9 "type": "module" // supported by Chrome, Firefox 128+, Safari 17+
10 }
11}
Execution context: Parsed by each browser’s extension host at install time. The safest approach for maximum compatibility is to declare both service_worker and scripts pointing at the same file — Chrome and Safari use service_worker, Firefox 113–120 fall back to scripts, and Firefox 121+ prefers service_worker. The type: "module" key enables ES module syntax in the background; it is ignored by browsers that do not support it.
The key behavioural difference: Chrome and Safari background service workers are non-persistent and will be evicted after ~30 seconds of inactivity. Firefox’s event page (via scripts) is also non-persistent but the eviction heuristic is different — Firefox keeps the event page alive longer. Do not rely on either being persistent; use chrome.alarms or chrome.storage for state that must survive eviction. The full persistence tradeoffs are covered in persistent vs non-persistent service workers.
Mozilla’s webextension-polyfill package provides a browser namespace on Chrome that wraps chrome.* in Promises and normalises behaviour across browsers. It is the recommended approach when targeting all three browsers:
1// Install: npm install webextension-polyfill
2import browser from "webextension-polyfill";
3
4// Now use browser.* everywhere — works on Chrome, Firefox, Safari
5const tabs = await browser.tabs.query({ active: true, currentWindow: true });
6const [{ result }] = await browser.scripting.executeScript({
7 target: { tabId: tabs[0].id! },
8 func: () => document.title,
9});
Execution context: The polyfill is bundled into each extension context (service worker, popup, content script) via your build tool. It detects the runtime environment and wraps accordingly. It does NOT polyfill APIs that do not exist on a browser — offscreen documents on Firefox/Safari still require feature detection. For a comprehensive list of what the polyfill covers and its known gaps, check the webextension-polyfill repository’s compatibility table.
Message passing works identically across all three browsers for runtime.sendMessage and runtime.onMessage. The tabs.sendMessage pattern also works cross-browser. See message passing architecture for implementation details including long-lived ports.
Scripting API differences are covered in detail in Scripting API & Dynamic Injection — including world: "MAIN" version gates and removeCSS Safari support.
Tabs API (tabs.query, tabs.onUpdated, tabs.onActivated) is consistent across all three browsers. The url and title properties in tabs.query require the tabs permission or a matching host permission on all browsers. See Tabs API & Window Management for safe querying patterns.
offscreen documents are Chrome-only. Firefox and Safari extensions needing a hidden DOM context must use a background page approach or restructure to avoid the need.action.openPopup() is not available on Safari. Extensions that need to open their popup programmatically must either show a notification instead or require a user gesture.webRequest in MV3 is available on Firefox but not Chrome or Safari. Do not use it for new cross-browser extensions — use declarativeNetRequest as the baseline.storage.session is only available on Chrome 102+, Firefox 115+, and Safari 17+. Use storage.local as the fallback for session-scoped data on older targets.executeScript and registerContentScripts divergence.