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.
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.
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
| Subsystem | Chrome / Edge | Firefox (≥ 109) | Safari (≥ 16.4) |
|---|---|---|---|
chrome.storage.local | Full support | browser.storage.local · native Promises | Full support; 10 MB default cap |
chrome.storage.sync | Syncs via Google account | Syncs via Firefox account | Maps to iCloud; stricter per-extension quota |
chrome.storage.session | Since Chrome 102 | Since Firefox 115 | Not supported as of Safari 17 |
chrome.runtime messaging | Full support | browser.runtime · Promises native | Full support |
declarativeNetRequest | Full support; 5 000 dynamic rules | Since Firefox 127; same 5 000 cap | Since Safari 16.4; lower static rule ceiling |
declarativeNetRequestFeedback | Supported | Supported since Firefox 128 | Not supported |
chrome.tabs | Full support | browser.tabs; getBrowserInfo available | Full support; Private Browsing restrictions |
chrome.windows | Full support | browser.windows | Full support |
chrome.scripting | Full support | browser.scripting since Firefox 102 | Since 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. 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.
Related
- Bookmarks, History & Downloads APIs — the user’s own records, queried safely.
- Chrome Storage API & Sync — quota, sync semantics, and change events.
- Declarative Net Request Rules — static and dynamic rule authoring.
- Message Passing Architecture — wiring isolated contexts together.
- Tabs API & Window Management — permission-safe tab queries and events.
- Scripting API & Dynamic Injection —
executeScriptandinsertCSSpatterns. - Alarms, Scheduling & Background Jobs — the timer that outlives the worker.
- Identity & OAuth Authentication — PKCE, redirect URLs and token storage.
- Cross-Browser API Compatibility — namespace adapters and divergence reference.
- Manifest V3 Architecture & Extension Lifecycle — service worker fundamentals and lifecycle patterns.