Core APIs & Cross-Browser Data Management

Master MV3 storage, messaging, declarativeNetRequest, tabs, and scripting APIs — with cross-browser compatibility patterns for Chrome, Firefox, and Safari extensions.

Manifest V3 ships a compact but opinionated API surface: storage that outlives the background service worker, declarative network rules that replace blocking listeners, a messaging bus that wires isolated contexts together, tabs/windows APIs that require explicit host permissions, and a scripting API that injects code on demand. Getting each subsystem right in isolation is necessary but not sufficient — the hard problems emerge at the seams, when a storage write must trigger a network rule update, or when a content script needs a response from a service worker that may have been evicted mid-flight. The Chrome Storage API & Sync guide is the best starting point for most extensions because durable state is the backbone everything else depends on.

MV3 Core API surface and execution context relationshipsFive API subsystems — storage, messaging, declarativeNetRequest, tabs/windows, and scripting — mapped to the three execution contexts that consume them: service worker, popup/options, and content script.Service workerbackground · event-drivenPopup / Optionsextension page · UIContent scriptisolated world · DOMchrome.storagelocal · sync · sessionchrome.runtimesendMessage · connectdeclarativeNetRequeststatic + dynamic ruleschrome.tabs / windowsquery · update · eventschrome.scriptingexecuteScript · insertCSSService worker accessPopup / options accessContent script access

Manifest permissions for the Core APIs

Declaring the wrong permission scope is the most common cause of silent failures. The snippet below shows the full declaration surface for every subsystem covered here, with comments explaining which grant enables what.

 1{
 2  "manifest_version": 3,
 3  "name": "My Extension",
 4  "version": "1.0",
 5  "permissions": [
 6    "storage",                         // chrome.storage.local/sync/session
 7    "declarativeNetRequest",           // static DNR rules (no host perms needed)
 8    "declarativeNetRequestFeedback",   // optional: lets you read matched rules
 9    "tabs",                            // read tab URLs/titles; query active tab
10    "scripting"                        // chrome.scripting.executeScript/insertCSS
11  ],
12  "host_permissions": [
13    "https://*/*",                     // required by scripting + DNR host actions
14    "http://*/*"
15  ],
16  "background": {
17    "service_worker": "sw.js",
18    "type": "module"
19  },
20  "declarative_net_request": {
21    "rule_resources": [
22      { "id": "ruleset_1", "enabled": true, "path": "rules.json" }
23    ]
24  }
25}

Execution context: Parsed by the browser at install and update time, not at runtime. Chrome and Edge treat declarativeNetRequestFeedback as an optional capability; Firefox accepts it since Manifest V3 support landed in v109; Safari silently ignores unrecognised permission strings rather than rejecting the extension.

Storage

chrome.storage is the only durable data layer available to every execution context. The service worker must treat it as its sole source of truth because all module-scope variables are wiped on eviction. The three areas differ in scope, quota, and replication behaviour: local (up to 10 MB, device-only), sync (100 KB total, replicated via the signed-in account), and session (in-memory for the browser session, cleared on restart).

 1// sw.js — read on every service worker cold start
 2chrome.runtime.onInstalled.addListener(async () => {
 3  const { schemaVersion } = await chrome.storage.local.get("schemaVersion");
 4  if (!schemaVersion || schemaVersion < 2) {
 5    await chrome.storage.local.set({ schemaVersion: 2, settings: { theme: "system" } });
 6  }
 7});
 8
 9// Respond to storage changes in every context simultaneously
10chrome.storage.onChanged.addListener((changes, area) => {
11  if (area === "sync" && changes.settings) {
12    applySettings(changes.settings.newValue);
13  }
14});

Execution context: Service worker background thread. All chrome.storage methods return Promises in MV3; await is safe here. Content scripts can call chrome.storage directly if the storage permission is declared — no messaging required. Firefox uses browser.storage with native Promises; Safari maps sync onto iCloud with tighter per-extension caps.

Messaging

No two extension contexts share a JavaScript heap, which means every cross-context operation requires an explicit message. One-shot sendMessage / onMessage handles request-response flows; long-lived connect / Port is better for streaming updates. Both channels use the structured-clone algorithm, so Map, Set, and class instances are not transferable.

 1// content-script.js — one-shot query
 2async function getBlockList(): Promise<string[]> {
 3  const response = await chrome.runtime.sendMessage({ type: "GET_BLOCK_LIST" });
 4  if (chrome.runtime.lastError) throw new Error(chrome.runtime.lastError.message);
 5  return response.urls ?? [];
 6}
 7
 8// sw.js — respond from the service worker
 9chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
10  if (msg.type === "GET_BLOCK_LIST") {
11    chrome.storage.local.get("blockList").then(({ blockList }) => {
12      sendResponse({ urls: blockList ?? [] });
13    });
14    return true; // keep the channel open for the async response
15  }
16});

Execution context: sendMessage can originate from any context; onMessage fires in the service worker and any open extension pages. return true from the onMessage listener is mandatory whenever sendResponse is called asynchronously — omitting it closes the port before the reply arrives. Firefox handles this identically; Safari occasionally drops responses if the listener does not return true within the same microtask.

Declarative Net Request

declarativeNetRequest (DNR) evaluates static and dynamic rules off the main thread — no content script or service worker is involved in per-request processing. This is both its strength (sub-millisecond overhead, no webRequest permission) and its constraint (no access to request bodies, no programmatic inspection). Static rules are declared in rules.json and compiled at install time; dynamic rules are written at runtime via updateDynamicRules and survive extension restarts.

 1// sw.js — add a dynamic redirect rule at runtime
 2async function blockTracker(domain: string) {
 3  const id = Math.floor(Math.random() * 1_000_000);
 4  await chrome.declarativeNetRequest.updateDynamicRules({
 5    addRules: [
 6      {
 7        id,
 8        priority: 10,
 9        action: { type: "block" },
10        condition: {
11          urlFilter: `||${domain}`,
12          resourceTypes: ["script", "xmlhttprequest", "image"],
13        },
14      },
15    ],
16    removeRuleIds: [],
17  });
18  await chrome.storage.local.set({ [`rule_${domain}`]: id }); // persist id for later removal
19}

Execution context: updateDynamicRules runs in the service worker and must complete before the rule takes effect on subsequent navigations — there is no synchronous path. Chrome caps dynamic rules at 5 000 per extension; Firefox enforces the same limit as of v127; Safari supports DNR since Safari 16.4 but enforces a lower static rule ceiling and does not yet expose declarativeNetRequestFeedback.

Tabs & Windows

chrome.tabs and chrome.windows require the tabs permission to read sensitive fields such as url and title. Omitting the permission still allows querying active-tab geometry but silently returns empty strings for those fields — a subtle bug that is hard to catch in testing. Always call chrome.tabs.query with the minimum viable filter and validate the result before reading tab.url.

 1// popup.js — safely read the active tab URL
 2async function getActiveTabUrl(): Promise<string | null> {
 3  const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
 4  if (!tab?.url) return null;                         // undefined if tabs permission absent
 5  if (tab.url.startsWith("chrome://")) return null;  // extension cannot inject here
 6  return tab.url;
 7}
 8
 9// sw.js — react to navigation events
10chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
11  if (changeInfo.status === "complete" && tab.url?.startsWith("https://")) {
12    scheduleContentSync(tabId);
13  }
14});

Execution context: tabs.query is available in the service worker, popup, options page, and content scripts (with tabs permission). chrome.windows is not available in content scripts. Firefox treats the activeTab grant differently from Chrome when the popup is not open — prefer explicit tabs permission for background access. Safari limits some tab event payloads in Private Browsing windows.

Permissions, security and CSP

Every permission in manifest.json is permanently visible to the Chrome Web Store review team and to users on the install dialog. Declare only what you need, request optional permissions at runtime where possible, and document the business reason for each host permission in your store listing.

The extension’s Content Security Policy (CSP) in MV3 disallows eval, new Function, inline event handlers, and remote script tags in extension pages by default. You cannot relax the script-src directive in MV3 — any attempt to add 'unsafe-eval' is rejected by Chrome. Audit bundler output: some transpilers emit eval for source maps or dynamic requires, which silently breaks at runtime.

For data in transit, prefer chrome.storage.local over sync for secrets and tokens. Synced data is replicated through the user’s account infrastructure, so anything written to storage.sync should be treated as potentially readable outside the device. Validate all onMessage payloads rigorously — a malicious web page can trigger chrome.runtime.sendMessage to your extension if it knows the extension ID.

Permissions that require a user gesture — activeTab, clipboardWrite — cannot be triggered from the service worker background. They must be invoked from a user-facing context such as the popup or via a chrome.action click handler. If you need the scripting permission to inject on arbitrary sites, host_permissions covering those origins must be declared; activeTab alone is insufficient for programmatic injection without a prior user gesture.

Cross-browser compatibility matrix

SubsystemChrome / EdgeFirefox (≥ 109)Safari (≥ 16.4)
chrome.storage.localFull supportbrowser.storage.local · native PromisesFull support; 10 MB default cap
chrome.storage.syncSyncs via Google accountSyncs via Firefox accountMaps to iCloud; stricter per-extension quota
chrome.storage.sessionSince Chrome 102Since Firefox 115Not supported as of Safari 17
chrome.runtime messagingFull supportbrowser.runtime · Promises nativeFull support
declarativeNetRequestFull support; 5 000 dynamic rulesSince Firefox 127; same 5 000 capSince Safari 16.4; lower static rule ceiling
declarativeNetRequestFeedbackSupportedSupported since Firefox 128Not supported
chrome.tabsFull supportbrowser.tabs; getBrowserInfo availableFull support; Private Browsing restrictions
chrome.windowsFull supportbrowser.windowsFull support
chrome.scriptingFull supportbrowser.scripting since Firefox 102Since Safari 16

What this section covers

The guides in this section each address one subsystem in depth. Chrome Storage API & Sync covers quota management, onChanged patterns, and cross-device sync semantics. Declarative Net Request Rules walks through static and dynamic rule authoring, priority scoring, and the migration path from the legacy webRequest API. The message passing architecture guide covers one-shot and port-based messaging, error handling, and the return true pitfall. Tabs API & Window Management goes deep on permission-gated field access, lifecycle events, and multi-window patterns. Scripting API & Dynamic Injection explains executeScript, insertCSS, and the world isolation model for injected code. Finally, the cross-browser API compatibility reference consolidates divergence details, polyfill strategies, and the namespace adapter pattern for shipping a single codebase across Chrome, Firefox, and Safari.

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.

Scripting API & Dynamic Injection

Use chrome.scripting in Manifest V3 to dynamically inject JavaScript and CSS — executeScript, registerContentScripts, world isolation, and activeTab patterns.

1 topics

  • • Injecting CSS and JS with executeScript

Chrome Storage API & Sync

Persist and synchronise extension state across devices with chrome.storage.sync — quotas, async patterns, change events, encryption and cross-browser adapters for Manifest V3.

3 topics

  • • Encrypting Sensitive Data in Chrome Storage Before Sync
  • • Handling Storage Quota Exceeded Errors in Chrome Extensions
  • • Local vs Sync Storage Performance Comparison

Declarative Net Request Rules

Master chrome.declarativeNetRequest static, dynamic, and session rulesets in MV3 — rule schema, modifyHeaders, redirect, block actions, quota limits, and getMatchedRules debugging.

2 topics

  • • Debugging declarativeNetRequest Rules That Don't Match
  • • Migrating webRequest to declarativeNetRequest Step by Step

Message Passing Architecture in MV3

Bridge popup, content script, and service worker contexts in Manifest V3 with one-time messages, long-lived ports, error boundaries, and reconnect patterns that survive worker termination.

3 topics

  • • Fixing 'Message Port Closed Before a Response Was Received' Errors
  • • Long-Lived Ports vs One-Time Messages in MV3
  • • Implementing Background Messaging Between Popup and Service Worker

Tabs API & Window Management

Control browser tabs and windows in Manifest V3: tabs.query, tabs.create, tab groups, onUpdated/onActivated events, activeTab permission, and cross-browser quirks.

2 topics

  • • Detecting Tab URL Changes Reliably in MV3
  • • Querying the Active Tab Safely in MV3