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.

Where request filtering moved between Manifest V2 and V3In MV2 the extension's own JavaScript decided each request; in MV3 the browser evaluates a static rule set without waking the extension at all.Request startsnetwork stackWake the background pageMV2 blocking listenerExtension JS decidesarbitrary logic, arbitrary delayMV3 evaluates the same intent without a round tripRequest startsnetwork stackRule enginein the browser processAction appliedblock, redirect, modifyHeaders
The extension is no longer in the request path — which is exactly why the rules must be expressible without running code.
 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.

Alarms and scheduling

The service worker cannot keep time: timers die with it, and it may be evicted thirty seconds after the last event. Anything that must happen later — a sync, a retry, a reminder, the next slice of a long job — is scheduled with chrome.alarms, which the browser persists and which wakes the worker when it fires. The key design decision is the kind of schedule: a periodic alarm for fixed intervals, a one-shot alarm scheduled with an absolute when for wall-clock times, and a chain of one-shot alarms with a cursor in storage for work longer than one worker lifetime.

1chrome.runtime.onInstalled.addListener(() => {
2  chrome.alarms.create("cache-trim", { periodInMinutes: 360 });
3});
4chrome.alarms.onAlarm.addListener((a) => {
5  if (a.name === "cache-trim") return trimCache();
6});

Execution context: the service worker, with the listener registered at the top level so an alarm that wakes a cold worker is delivered. Packed extensions are clamped to a one-minute minimum period on Chrome; Firefox is more permissive; Safari may defer delivery under power management. The full treatment is in alarms and scheduled background jobs.

Identity and authentication

Extensions that talk to a user’s account need OAuth, and MV3 changes the shape of it. The service worker cannot host a login page, a popup closes the moment focus moves to one, and the extension is a public client that cannot keep a secret. chrome.identity.launchWebAuthFlow solves the first two by hosting the provider’s page in a browser-managed window and returning the final redirect URL; PKCE solves the third by replacing the client secret with a per-request proof.

1const redirect = chrome.identity.getRedirectURL();          // https://<id>.chromiumapp.org/
2const result = await chrome.identity.launchWebAuthFlow({ url: authUrl(redirect), interactive: true });

Execution context: the service worker, reached from a user gesture. Tokens then live in storage.session for access and, only where needed, storage.local for refresh. Firefox and Safari support launchWebAuthFlow but not Chrome’s getAuthToken, which makes the web-auth flow the portable choice. See identity and OAuth authentication.

Dynamic scripting

chrome.scripting replaced MV2’s string-based executeScript with an API that injects functions or files, never code strings. It covers three needs: one-shot injection after a user gesture, persistent registrations that run on future navigations, and CSS insertion and removal. Choosing among them decides the permission cost: injection under activeTab needs no host permission at all, while registrations require access to every site they match.

1chrome.action.onClicked.addListener((tab) =>
2  chrome.scripting.executeScript({ target: { tabId: tab.id }, files: ["content/summarise.js"] }));

Execution context: the service worker, inside the click handler where the activeTab grant is live. Functions passed as func are serialised, so they cannot close over worker variables; arguments travel through args and results come back per frame. See scripting API dynamic injection.

Cross-browser compatibility

Every API in this section exists on Chrome; most exist on Firefox and Safari; several behave differently in ways that matter. Firefox exposes promise-based browser.* natively and runs its background as an event page with a DOM. Safari follows Chrome’s shape more closely but has lower limits, delayed alarms and missing APIs. The durable approach is to detect capabilities rather than browsers, keep one promise-based code path, and record, per feature, whether each engine supports it identically, through an adapter, in a degraded form, or not at all — the approach set out in cross-browser API compatibility.

1const api = globalThis.browser ?? globalThis.chrome;
2const hasSessionStorage = !!api.storage?.session;

Execution context: any extension context. Probing for the API you are about to use is more reliable than parsing a user agent, and it keeps working when a browser ships the missing feature.

The user’s own browser data

A further group of APIs reaches the user’s records rather than the page: bookmarks, history, downloads, topSites and sessions. They are simple to call and expensive to declare — each produces a prominent install warning and closer review — and they return data at a scale development profiles never exercise. Query with bounds, process large sets in alarm-driven slices, react to change events by scheduling a single reconciliation, and keep every derived result on the device. Where a lighter surface answers the same question, such as topSites instead of full history, prefer it. See bookmarks, history and downloads APIs.

1const recent = await chrome.history.search({ text: "", startTime: Date.now() - 7 * 864e5, maxResults: 500 });

Execution context: the service worker or an extension page, never a content script. maxResults: 0 means unlimited rather than none, which is why every query in this family should set it explicitly.

How the Core APIs fit together

The APIs in this section are rarely used alone. A typical feature combines several: a content script notices something on a page and messages the worker; the worker checks storage, perhaps calls an authenticated API, writes a result and updates the badge; an alarm later reconciles or retries; the popup reads the result from storage the next time it opens. Designing with that flow in mind — storage as the shared source of truth, messages for requests, alarms for anything deferred — is what makes each individual API straightforward to use correctly.

Deciding where state lives

Nearly every design question in this section comes back to where a piece of state should live, because the service worker cannot hold it. There are five candidates, and each has a clear use. storage.session holds state that should survive worker eviction but not a browser restart: tab ids, in-flight job cursors, access tokens, render-ready summaries for the popup. storage.local holds durable, device-specific data: caches, indexes, logs, settings that describe this machine. storage.sync holds the small set of user preferences that should follow the user to other devices, within its tight quota. IndexedDB holds large collections that need querying one record at a time. Nothing at all — recompute on demand — is right for anything cheap to derive and risky to cache.

Choosing deliberately avoids the two most common problems: state kept in worker memory that vanishes on eviction, and one enormous storage key that every reader must deserialise to use a single field. The trade-offs between the storage areas are measured in local vs sync storage performance comparison, and the move to IndexedDB is covered in choosing between chrome.storage and IndexedDB.

Performance across the APIs

Most individual API calls in this section are fast — a few milliseconds for a small storage read, a message round trip to a warm worker, or a tab query. What makes an extension slow is how often those calls are made and what sits in front of them. A cold service worker adds its start-up time to the first event after every idle period; a storage read inside a loop multiplies a small cost by the loop length; a listener on tabs.onUpdated without a filter wakes the worker several times for every page load.

The remedies recur throughout the guides in this section: filter events at the source, coalesce bursts into one piece of work, read storage once per handler rather than per item, keep the worker’s module graph small, and render user-facing surfaces from pre-shaped summaries rather than waiting on the worker. None of them requires exotic techniques; they require noticing where a cheap operation is being repeated.

Testing the Core APIs

The APIs in this section divide cleanly for testing. Logic that transforms data — parsing settings, ranking search results, building rules, migrating schemas — is pure and belongs in fast unit tests in Node. Handlers that call the APIs take them as injected dependencies, so tests pass small fakes with realistic copy and event semantics. And the wiring — listeners registered at the top level, messages crossing contexts, rules actually matching requests, alarms rebuilt after an update — is covered by a small set of end-to-end tests against the real extension, including tests that stop the worker to force cold starts. That split keeps most tests fast while still exercising the lifecycle behaviour that causes most real bugs, as described in testing message handlers in isolation.

Security posture across the APIs

Every API in this section moves data across a boundary: from a page into a content script, from a content script into the worker, from the worker to a remote server, from one device to another through sync. Each crossing is a place to validate. Messages arriving at the worker are checked for sender and shape before anything acts on them; page-derived strings are never interpolated into markup; tokens stay in session storage and never reach content scripts; and anything leaving the device — sync payloads, error reports, API calls — contains only what the feature needs. Applying the same rule at every boundary is simpler, and more reliable, than deciding case by case which crossings deserve care. The detail is in extension security and CSP hardening.

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
Core API surface across the three enginesNamespace, promise support, declarativeNetRequest, scripting and offscreen availability in Chrome, Firefox and Safari.SurfaceChrome 120+Firefox 121+Safari 17+Namespacechrome.*browser.* (+chrome)browser.* (+chrome)Promise-returning APIsMV3 nativeNativeNativedeclarativeNetRequestFullMost of itSubsetchrome.scriptingFullFullFullchrome.offscreenChrome 109+Not availableNot availableBackground contextService workerEvent page or workerService worker
The namespace and the background model are the two divergences that force real code branches; the rest is API-shape parity.

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. Alarms, Scheduling & Background Jobs covers the only timer that survives worker eviction, its minimum period and its restart behaviour. Identity & OAuth Authentication covers signing users in from a client that cannot keep a secret — PKCE, browser-generated redirect URLs, and token storage that survives eviction. 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.

The newest topic in this section covers the APIs that reach the user’s own records rather than the page in front of them: bookmarks, history and downloads APIs, including how to query them at scale from an evictable worker and how to get their sensitive permissions through review. Scheduling work is covered in more depth too, from chaining alarms for long-running jobs to scheduling daily and weekly syncs that land at the right local time.

Bookmarks, History & Downloads APIs

Read and write the user's bookmarks, browsing history and downloads from a Manifest V3 extension — permissions, event models, quotas and the review scrutiny these APIs attract.

5 topics

  • • Justifying Sensitive Data Permissions
  • • Managing Downloads from an Extension
  • • Reading and Writing Bookmarks Safely
  • + 2 more

Identity & OAuth Authentication

Sign users in from a Manifest V3 extension: launchWebAuthFlow with PKCE, redirect URLs per browser, token storage that survives worker eviction, and refresh without a secret.

6 topics

  • • OAuth in Firefox and Safari Extensions
  • • Signing Users Out and Revoking Tokens
  • • Choosing Between getAuthToken and launchWebAuthFlow
  • + 3 more

Alarms & Scheduling Background Jobs

Schedule work in an MV3 extension with chrome.alarms: minimum periods, surviving restarts, resumable chunked jobs, alarm naming, and the Chrome, Firefox and Safari differences.

6 topics

  • • Alarms vs setTimeout in Service Workers
  • • Auditing Scheduled Alarms with getAll
  • • Chaining Alarms for Long-Running Jobs
  • + 3 more

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.

6 topics

  • • Building a Capability Matrix for Your Extension
  • • Typing chrome and browser APIs in TypeScript
  • • Using the webextension-polyfill in MV3
  • + 3 more

Scripting API & Dynamic Injection

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

6 topics

  • • Injecting into iframes and All Frames
  • • Passing Arguments to Injected Functions
  • • Registering Content Scripts at Runtime
  • + 3 more

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.

8 topics

  • • Batching Storage Writes to Stay Under Quota
  • • Choosing Between chrome.storage and IndexedDB
  • • chrome.storage.onChanged Listener Patterns
  • + 5 more

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.

6 topics

  • • Dynamic vs Static Rulesets
  • • Redirecting and Rewriting Headers with Rules
  • • Scoping Rules to a Single Tab
  • + 3 more

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.

8 topics

  • • Messages Sent While the Worker Is Starting
  • • Wrapping Message Passing in Promises
  • • Broadcasting Messages to All Tabs
  • + 5 more

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.

7 topics

  • • Handling Restricted URLs and Tab Permissions
  • • Opening and Tracking Extension Pages in Tabs
  • • Moving and Grouping Tabs Programmatically
  • + 4 more