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.

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.

chrome.storage.sync data flow across extension contexts and devicesThe popup, content script and service worker all read and write through chrome.storage.sync, which persists to disk and replicates asynchronously to the same user's other signed-in browser profiles.PopupUI contextContent scriptisolated worldService workerbackgroundchrome.storage.syncasync · disk-backed8 KB/item · 100 KB capSync backendGoogle accountOther devicesauto-replicated

Prerequisites checklist

Before writing a single set() call, confirm the following are wired up:

  • storage permission declared in manifest.json — without it every call throws synchronously.
  • A versioned schema strategy, because synced data outlives your extension’s releases and a v2 build will read v1 shapes.
  • A decision on local vs sync per key — see the local vs sync storage performance comparison for the latency and quota trade-offs.
  • A plan for quota failures, covered in handling storage quota exceeded errors.

1. Declare the permission

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.

2. Asynchronous read and write

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.

3. React to cross-context changes

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.

How one storage write reaches every open extension contextA write from the options page fires storage.onChanged in the service worker, the popup and every content script that registered a listener.Options pagestorage.syncService workerContent scriptset({ theme: 'dark' })commit + quota accountingonChanged(changes, 'sync')wakes an evicted workeronChanged(changes, 'sync')only in frames with a listeneronChanged fires in the writer too
onChanged is a broadcast, not a reply: the writer never learns who consumed the change.
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.

4. Choosing a storage area for each piece of state

chrome.storage is four areas with different lifetimes, limits and audiences, and most storage problems start with a value placed in the wrong one. Decide per key, using what the value describes and how long it should live.

storage.session is held in memory, survives service-worker eviction, and is cleared when the browser closes. It is the right home for anything tied to the current session: tab ids, in-flight job cursors, access tokens, retry counters, and render-ready summaries that let the popup paint instantly. Its limit is around 10 MB, and by default content scripts cannot read it — a useful property for tokens.

storage.local persists on the device across restarts and updates. It holds caches, indexes, logs, device-specific settings such as panel widths, and anything too large or too frequently written for sync. Its default quota is about 10 MB, raised by the unlimitedStorage permission, and it has no per-minute write limit.

storage.sync follows the signed-in user across devices, within a 100 KB total and 8 KB per-item budget and a rate limit on writes. It is for preferences that describe the user rather than the machine: theme, language, enabled sites, feature toggles. When the user is not signed in or has sync disabled, it silently behaves like local storage.

storage.managed is read-only to the extension and written by an administrator through enterprise policy, for extensions deployed in organisations. Values there override user settings and should be shown as locked in the UI.

1await chrome.storage.session.set({ summary: { unread: 3, at: Date.now() } });   // session: popup first paint
2await chrome.storage.local.set({ articleIndex });                                  // local: large, device-bound
3await chrome.storage.sync.set({ settings: { theme: "dark" } });                    // sync: small, follows the user

Execution context: any extension context. The same data written to the wrong area produces the classic failures: sync quota errors from a cache, a token surviving a restart it should not, preferences that fail to follow the user. The session/local split is explored in chrome.storage.session vs local.

5. Designing keys and value shapes

A storage key is a unit of reading, writing and conflict, and its design has more effect on performance and correctness than any API call. Three guidelines cover most cases.

Split by access pattern. A value read on every popup open should not share a key with a large collection read once a day, because every read deserialises the whole value. One enormous state key is the most common storage performance problem in extensions.

Split lists that are edited from several places. storage.sync resolves concurrent changes per key, last writer wins. A list of enabled sites stored under one key loses one device’s edit when two devices change it at the same time; one key per site keeps both edits.

Version the shape. Store a schema number next to data whose shape will change, and read everything through one parser that fills defaults and tolerates keys it does not recognise. That makes updates, rollbacks and imports safe, as described in migrating storage schema between versions.

6. Writing without hitting limits

Writes are where quotas bite. storage.sync allows 120 write operations per minute and 1,800 per hour, and one set call counts as one operation regardless of how many keys it carries. A settings page that writes on every keystroke can exhaust the minute’s allowance while a user drags a slider.

The fix is to coalesce: buffer changes in memory in extension pages and flush them together after a short delay or when the page is hidden, and in the service worker, buffer durably in storage.session and flush on an alarm. Guard against write loops as well — a storage.onChanged listener that writes back in response can saturate the quota in seconds. The patterns are in batching storage writes to stay under quota.

7. When chrome.storage is the wrong tool

chrome.storage is a key-value store that serialises whole values. It has no queries, no indexes and no way to update one record inside a larger value without rewriting it. Once an extension stores a growing collection — thousands of saved articles, a search index, cached responses — IndexedDB becomes the better home, with chrome.storage keeping only small summaries that surfaces need to render instantly. IndexedDB is available in the service worker and extension pages on the extension’s own origin, though not to content scripts, which see the page’s database instead. See choosing between chrome.storage and IndexedDB.

8. Testing storage code

Storage code is easy to test in Node with a small fake that reproduces the real API’s behaviour: values copied on every read and write, asynchronous resolution, and an onChanged event after every write. Those three properties are exactly the ones that cause false passes when a fake gets them wrong — a test that mutates a returned object and expects storage to change, or one that misses a write loop because events never fire. Pair the fake with storage fixtures captured from each released version, run through the current migration code on every build, and the most damaging class of storage bug — data lost or corrupted after an update — is caught before it ships. See contract testing a storage schema.

9. Reading storage efficiently

Individual reads are fast — a few milliseconds for a small key — but they add up when they sit on a hot path. Three patterns keep reads cheap. Read several keys in one call rather than one call per key. Read once per handler and pass the data down, rather than calling get inside a loop. And in the service worker, memoise frequently read values for the worker’s lifetime, invalidating the cached copy from a storage.onChanged listener, so a busy handler does not re-read settings on every message.

1let settingsPromise = null;
2export const settings = () => (settingsPromise ??= chrome.storage.sync.get("settings").then((r) => r.settings ?? {}));
3chrome.storage.onChanged.addListener((c, area) => { if (area === "sync" && c.settings) settingsPromise = null; });

Execution context: the service worker. The cache is lost on eviction, which is exactly right: the next wake reads fresh data once, and every later handler in that lifetime reuses it. Measuring where reads actually cost time is covered in measuring storage read and write latency.

10. Storage as the shared source of truth

The deepest benefit of chrome.storage in an MV3 extension is architectural. Because every context — the worker, the popup, the options page, the side panel, content scripts — can read the same keys and receive the same change events, storage can serve as the single source of truth that ties them together. The worker writes results; every surface renders from storage and updates when it changes; nothing pushes state directly from one context to another. That arrangement survives every lifecycle event MV3 can throw at it: a popup that opens while the worker is asleep still renders correctly, a side panel stays in step without a subscription protocol, and a change made on another device appears everywhere through sync. Extensions built this way have far fewer consistency bugs than those that pass state around in messages, because there is only ever one place to look for the current value — the pattern explored in storage.onChanged listener patterns.

11. Privacy and storage

Whatever the extension stores, it holds on the user’s behalf. Keep sensitive values out of synced storage, where they leave the device; keep tokens in session storage, where they do not survive a restart; encrypt data that would be damaging if the profile directory were copied; and remove derived data when the user revokes the permission or turns off the feature that produced it. Stating in the privacy policy exactly what is kept, where and for how long is easier when the storage design already makes those answers simple — see encrypting sensitive data in chrome.storage.

Finally, give users a way to see and clear what the extension stores about them. A short data section on the options page — what is kept, how much space it uses via getBytesInUse, and a clearly labelled button to delete it — turns an abstract privacy promise into something users can verify for themselves, and it is also the fastest way for support to reset a profile that has got into a bad state.

Make the delete button honest about scope: say whether it clears only this device or also the synced settings on every signed-in device, and offer both where it matters.

MV3 constraints to design around

  • Item size: 8 KB per key (QUOTA_BYTES_PER_ITEM). Large blobs must be chunked or routed to storage.local.
  • Total sync: 100 KB across all synced keys (QUOTA_BYTES).
  • Write rate: ~120 writes/minute and ~1,800/hour; bursts beyond this reject with MAX_WRITE_OPERATIONS_PER_MINUTE.
  • No globals survive eviction: the worker dies after ~30s idle, so storage — not a module-scope variable — is your source of truth.
  • Structured-clone only: values must be JSON-serialisable; Map, Set, Date and class instances are flattened or rejected.

Cross-browser notes

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.

Security and sensitive data

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.

Quota reference

MetricLimit
Per-item size (sync)8 KB
Total sync storage100 KB
Write operations~120 / minute, ~1,800 / hour
Total storage.local10 MB (unlimitedStorage removes the cap)
Storage area capacity against the sync ceilingBar chart comparing the byte capacity of storage.sync, its per-item cap, storage.session and storage.local.sync — per item8 KBQUOTA_BYTES_PER_ITEMsync — total100 KBQUOTA_BYTES across all keyssession — total10240 KBin-memory, cleared on browser exitlocal — total10240 KBunlimitedStorage removes the cap
storage.local is two orders of magnitude larger than sync — the ceiling, not the API shape, decides where a key belongs.

Further guides in this topic

The guides below go deeper into specific chrome storage api and sync problems that the sections above only touch on — each one starts from a concrete symptom and ends with a way to verify the fix.

  • Batching Storage Writes to Stay Under Quota — chrome.storage.sync enforces per-minute and per-hour write limits. Coalesce writes behind a queue, collapse repeated keys, and recover cleanly when a write is rejected.
  • Choosing Between chrome.storage and IndexedDB — When an MV3 extension outgrows chrome.storage.local — query patterns, blob storage, quota and eviction differences — and how to run both stores side by side without drift.

Batching Storage Writes to Stay Under Quota

chrome.storage.sync enforces per-minute and per-hour write limits. Coalesce writes behind a queue, collapse repeated keys, and recover cleanly when a write is rejected.

Choosing Between chrome.storage and IndexedDB

When an MV3 extension outgrows chrome.storage.local — query patterns, blob storage, quota and eviction differences — and how to run both stores side by side without drift.

chrome.storage.onChanged Listener Patterns

Use storage.onChanged as an MV3 event bus: filtering by area and key, avoiding echo loops when the writer also receives the event, and keeping popup, options page and service worker in step.

chrome.storage.session vs local

Choose between chrome.storage.session and chrome.storage.local in MV3: lifetime, quota, content script access levels, and the tab-scoped state pattern session storage was designed for.

Migrating Storage Schema Between Versions

Version and migrate chrome.storage data across extension updates: a schemaVersion key, forward-only migration steps in onInstalled, and recovering from a half-applied migration.

Encrypting Sensitive Data in Chrome Storage Before Sync

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.

Handling Storage Quota Exceeded Errors in Chrome Extensions

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.

Local vs Sync Storage Performance Comparison

Benchmark chrome.storage.local against chrome.storage.sync in MV3: latency, throughput, quota limits and tiered-write patterns to keep your extension responsive.