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.

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.

Chrome, Firefox, and Safari MV3 API surface comparisonThree browser columns showing relative API completeness for storage, scripting, declarativeNetRequest, and background context in Manifest V3.Chrome / EdgeFirefoxSafaristorage.syncscriptingDNRbackgroundFull supportPartial / caveatsNot supported

Prerequisites checklist

Before targeting multiple browsers:

  • Namespace decision: Choose 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.
  • Minimum browser versions: Pin your target — Firefox 112 for registerContentScripts, Safari 17 for world: "MAIN", Safari 16.4 for removeCSS. Features below these versions need workarounds.
  • Manifest declaration review: Firefox MV3 allows background.scripts as a fallback where Safari requires background.service_worker. Chrome ignores background.scripts entirely in MV3.
  • Test on all targets before release: The stores for Chrome, Firefox, and Safari each run their own review, but only Chrome’s automated checks catch API mismatches at review time.

Namespace and Promise model

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/Edge: chrome.* namespace; all APIs return Promises in MV3 (callbacks still accepted for backwards compatibility).
  • Firefox: browser.* namespace natively; chrome.* is aliased to browser.* without Promises, so avoid mixing — use browser.* consistently.
  • Safari: Supports both 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 compatibility table

API / FeatureChrome / EdgeFirefoxSafari
storage.localFull; 10 MB capFull; 10 MB capFull; 5 MB cap
storage.syncFull; 100 KB / 8 KB per itemFull; 100 KB / 8 KB per item (syncs via Firefox account)Partial; backed by iCloud, stricter throttling
storage.sessionChrome 102+Firefox 115+Safari 17+
storage.onChangedFullFullFull; may coalesce rapid changes
scripting.executeScriptFullFull (Firefox 102+)Full (Safari 16+)
scripting.executeScriptworld: "MAIN"FullFirefox 102+Safari 17+
scripting.insertCSSFullFull (Firefox 102+)Full (Safari 16+)
scripting.removeCSSFullFirefox 102+Safari 16.4+
scripting.registerContentScriptsFullFirefox 112+Safari 17+
declarativeNetRequest (static rules)FullFirefox 113+Safari 15.4+ (limited)
declarativeNetRequest (dynamic rules)Full; 5 000 rulesFirefox 113+; 5 000 rulesSafari 15.4+; 150 rules max
declarativeNetRequest.updateSessionRulesChrome 111+Firefox 113+Not supported
declarativeNetRequestmodifyHeadersFullFirefox 113+Not supported
webRequest (read-only)Deprecated in MV3; requires declarativeNetRequestSupported in MV3 (Firefox keeps it)Not supported
background.service_workerRequired in MV3Supported from Firefox 121Required in MV3
background.scripts (event page)Not supported in MV3Accepted as fallback (Firefox 113–120 compatibility)Not supported in MV3
background.persistentRemoved in MV3IgnoredIgnored
action (unified)FullFullFull
action.openPopup()Chrome 99+Firefox 118+Not supported
runtime.sendMessageFullFull (Promises)Full
tabs.queryFullFullFull
tabs.executeScriptRemoved in MV3Still works in MV2 modeRemoved in MV3
offscreen documentsChrome 109+Not supportedNot supported
Promise-based APIsFull (MV3)Full (native)Full (via browser.*)
chrome.* callback styleSupportedAliased (no Promises)Partial shim

storage.sync quota differences

All three browsers honour the same quota constants (QUOTA_BYTES, QUOTA_BYTES_PER_ITEM, MAX_ITEMS) on paper, but the behaviour under load differs:

  • Chrome: Enforces 100 KB total / 8 KB per item strictly. MAX_WRITE_OPERATIONS_PER_MINUTE (~120) is enforced; exceeding it rejects with a quota error that names the constant.
  • Firefox: Enforces the same nominal quotas but sync is backed by a Firefox Account. Users without a Firefox Account still get storage.sync as local-only storage with the same quota constants.
  • Safari: Backed by iCloud. The 100 KB / 8 KB constants are declared but Safari enforces additional iCloud-level throttling that is not documented as an extension API limit. Large rapid sync writes may silently stall on Safari. Test storage.sync writes under real iCloud network conditions.

For storage patterns and error handling, see Chrome Storage API & Sync.

declarativeNetRequest support differences

The gap between Chrome and Firefox/Safari on declarativeNetRequest is the largest practical compatibility challenge for ad-blocking or privacy extensions:

  • Chrome: Full support including updateSessionRules, modifyHeaders, redirect with regexSubstitution, and 30 000 static rules (5 000 dynamic). The Chrome Web Store requires declarativeNetRequest for any network-modifying extension.
  • Firefox: Added 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.
  • Safari: Added basic 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.

Background context declaration

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.

The webextension-polyfill

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.

Cross-browser notes

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.

MV3 Constraints box

  • 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.
  • Safari dynamic rule cap of 150 (vs 5 000 on Chrome/Firefox) means that extensions with large dynamic rule sets need either a static-rules approach for Safari or a reduced feature set.
  • 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.