Every extension needs to persist something — settings, tokens, cached data, user content. The platform gives you four options, and picking the wrong one shows up later as a quota error, data that silently doesn’t sync, or a rewrite when your data outgrows where you put it.
Here’s what each is actually for.
The four options at a glance
| Option | Persists across | Synced across devices | Rough quota | Shape |
|---|---|---|---|---|
chrome.storage.local | browser restarts | No | ~10 MB (raised from 5 MB) | key/value |
chrome.storage.sync | browser restarts | Yes | ~100 KB total, ~8 KB/item | key/value |
chrome.storage.session | until browser closes | No | ~10 MB | key/value |
| IndexedDB | browser restarts | No | large (disk-based, %-of-disk) | structured DB |
Everything below is the detail behind that table.
chrome.storage.local — the default
This is where most extension state belongs. It’s key/value, asynchronous, persists across browser restarts, and is available from every context (service worker, content scripts, popup, options). The quota was historically 5 MB and is now around 10 MB for unpacked/most extensions — generous for settings and modest caches, but not where you put large or unbounded data.
Reach for it by default. Only move off it when you have a specific reason below.
await chrome.storage.local.set({ theme: "dark" });
const { theme } = await chrome.storage.local.get("theme");
chrome.storage.sync — small, cross-device, and quota-tight
sync looks like local but Chrome replicates it across the user’s
signed-in devices. That’s genuinely useful for user settings — a
handful of small values the user expects to follow them.
The trap is the quota. It is small and it is enforced hard:
- ~100 KB total across everything you store.
- ~8 KB per item (per key).
- Plus limits on write operations per minute and per hour — sync will throttle you if you write too often.
If you treat sync like local and dump a cache or a growing list into
it, you will hit QUOTA_BYTES_PER_ITEM or the write-rate limit, and the
failure often surfaces far from the code that caused it. Sync is for
preferences, not data.
It also introduces a concern local doesn’t have: conflicts. Two
devices can write the same key while offline and reconcile later. If
that matters for your data, you need to handle the onChanged events
and decide a merge strategy — last-write-wins is what you get by default,
and it silently drops one side.
chrome.storage.session — in-memory, cleared on close
session holds data only for the life of the browser session — it’s
cleared when the browser closes. Its reason to exist in MV3 is specific:
because the service worker is
ephemeral and loses its in-memory
variables on every eviction, you need somewhere to keep short-lived
runtime state that survives worker restarts but shouldn’t be written
to disk.
Auth tokens for the current session, decrypted secrets, “is the user
mid-flow” flags — things you want to outlive a worker eviction but not
persist to disk or leak across sessions. By default session is not
exposed to content scripts; you can opt in with
setAccessLevel if you need it there.
IndexedDB — when key/value isn’t enough
All three chrome.storage areas are key/value stores. The moment you
need any of the following, you’ve outgrown them:
- Large or unbounded data — more than a few MB, or data that grows with usage (captured items, offline document caches, history).
- Querying — finding records by a field, ranges, sorting, indexes, rather than fetching one known key at a time.
- Structured records with relationships.
IndexedDB is the browser’s real database: transactional, indexed, disk-backed, with a quota measured as a percentage of available disk rather than a fixed small cap. It’s available in the service worker and in content scripts (via the page, with caveats). The cost is ergonomics — the raw IndexedDB API is famously awkward, which is why most teams wrap it.
A decision guide
- A few small user settings that should follow the user across
devices? →
chrome.storage.sync. Keep it under the KB budget and handle conflicts if the data warrants it. - General extension state, settings, small-to-medium caches, single
device? →
chrome.storage.local. The default. - Runtime state that must survive service-worker eviction but not
persist across browser restarts (session tokens, in-flight flags)?
→
chrome.storage.session. - Large, growing, or queryable data? → IndexedDB.
The migration you don’t want to do later
The most expensive mistake here is starting with chrome.storage.local
for something that was always going to be large or queryable, then
rewriting to IndexedDB once you hit the wall — and having to migrate
every existing user’s data in place, in a service worker that can die
mid-migration. If you can see that a data set will grow unbounded or
need querying, start it on IndexedDB.
And whatever you choose, remember that schema changes are forever:
users upgrade your extension in place, carrying their old data shape
with them. Versioned, resumable migrations on runtime.onInstalled
aren’t a nice-to-have — they’re the difference between an update that
ships and one that corrupts data for the users who had the most of it.
Part of a series mapping every layer of the browser-extension stack.
See also the MV3 service worker
lifetime, which is why
chrome.storage.session exists in the first place.