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.
Persist and synchronise extension state across devices with chrome.storage.sync — quotas, async patterns, change events, encryption and cross-browser adapters for Manifest V3.
Every Manifest V3 extension faces the same hard constraint: the background service worker is evicted after roughly 30 seconds of inactivity, taking all in-memory state with it. The chrome.storage API is the durable layer that survives that eviction, and chrome.storage.sync extends it across every device the user is signed into. Master this and your extension’s preferences, auth flags and cached configuration stay coherent whether the worker is alive, asleep, or running on a second laptop. This guide is part of Core APIs & Cross-Browser Data Management.
The gotcha that bites first: chrome.storage.sync is not a bigger localStorage. It is asynchronous, quota-limited to 8 KB per item and 100 KB total, rate-limited per minute, and the payload is transmitted to Google’s servers. Treat it as a small, slow, replicated key-value store and design around those limits from the first commit.
Before writing a single set() call, confirm the following are wired up:
storage permission declared in manifest.json — without it every call throws synchronously.local vs sync per key — see the local vs sync storage performance comparison for the latency and quota trade-offs.The storage permission is not a host permission and triggers no install-time warning, so there is no reason to defer it.
1{
2 "manifest_version": 3,
3 "name": "Sync Demo",
4 "permissions": ["storage"] // grants storage.local, storage.sync and storage.session
5}
Execution context: Root manifest.json, parsed by the extension host at install time. Identical key on Chrome, Edge and Firefox; Safari accepts it but enforces its own iCloud-backed sync quotas.
Every chrome.storage method returns a Promise in MV3. Batch related keys into one object to collapse multiple IPC round-trips into a single write, and never assume a write has landed until its Promise resolves.
1export async function syncUserPreferences(prefs: Record<string, unknown>) {
2 try {
3 await chrome.storage.sync.set({ userPrefs: prefs, version: 3 });
4 } catch (error) {
5 console.error("Sync write failed:", error);
6 throw error; // surface quota/rate errors to the caller
7 }
8}
9
10export async function loadUserPreferences(): Promise<Record<string, unknown>> {
11 const { userPrefs } = await chrome.storage.sync.get("userPrefs");
12 return userPrefs ?? {};
13}
Execution context: Runs in the service worker or any extension page holding the storage permission. await yields the event loop rather than blocking it. Firefox exposes the same surface under browser.storage.sync with native Promises; Chrome’s chrome.* namespace became Promise-based in MV3.
A write in the popup must be visible to the service worker and any open options page. Instead of polling, subscribe to chrome.storage.onChanged and filter by areaName. This is the backbone of reactive extension UIs and pairs naturally with the message passing architecture when a context needs a push rather than a pull.
1chrome.storage.onChanged.addListener((changes, areaName) => {
2 if (areaName !== "sync") return;
3 if (changes.userPrefs) {
4 const next = changes.userPrefs.newValue;
5 broadcastStateUpdate(next); // re-render any live surfaces
6 }
7});
Execution context: Register at the top level of the service worker so it re-binds on every cold start. The listener fires in every extension context simultaneously — guard against feedback loops. Firefox and Safari fire the same event; Safari may coalesce rapid changes into a single callback.
QUOTA_BYTES_PER_ITEM). Large blobs must be chunked or routed to storage.local.QUOTA_BYTES).MAX_WRITE_OPERATIONS_PER_MINUTE.Map, Set, Date and class instances are flattened or rejected.Firefox and Edge expose the identical API under the browser.storage.sync namespace and enforce their own per-extension sync quotas. Safari maps sync storage onto iCloud and is the most likely to throttle or silently cap. A thin runtime adapter removes the namespace branch from your call sites:
1const storage = (typeof browser !== "undefined" ? browser : chrome).storage;
2
3export async function safeSyncWrite(data: Record<string, unknown>) {
4 try {
5 await storage.sync.set(data);
6 } catch (err) {
7 if (String((err as Error).message).includes("QUOTA_BYTES")) {
8 await handleQuotaExceeded(data);
9 }
10 throw err;
11 }
12}
Execution context: Shared utility module imported by every context. Resolves the chrome vs browser namespace at runtime so the rest of the codebase stays vendor-neutral. For a full divergence table see the cross-browser API compatibility reference.
Sync storage is replicated through the user’s account, so never write tokens, PII or secrets in clear text. Keep device-bound secrets in chrome.storage.local, and where you must sync sensitive material, encrypt it first — the full pattern lives in encrypting sensitive data in chrome storage.
| Metric | Limit |
|---|---|
Per-item size (sync) | 8 KB |
Total sync storage | 100 KB |
| Write operations | ~120 / minute, ~1,800 / hour |
Total storage.local | 10 MB (unlimitedStorage removes the cap) |
QUOTA_BYTES gracefully.Use SubtleCrypto AES-GCM to encrypt extension secrets before writing to chrome.storage.sync, with key derivation, IV rotation, and safe key storage patterns for MV3.
Catch QUOTA_BYTES, QUOTA_BYTES_PER_ITEM, and MAX_WRITE_OPERATIONS_PER_MINUTE errors in MV3, chunk large values, and fall back to local storage with retry-backoff.
Benchmark chrome.storage.local against chrome.storage.sync in MV3: latency, throughput, quota limits and tiered-write patterns to keep your extension responsive.