If you have written any non-trivial Manifest V3 extension, you have hit the question this post is about: where does the state actually live? Manifest V3 gives you four chrome.storage areas plus IndexedDB plus the OPFS, all with different quotas, different APIs, different performance characteristics, and almost identical-looking type signatures. Picking wrong is one of the most common reasons MV3 extensions break under load — the service worker survives, the architecture survives, and the queue silently corrupts because a write got rate-limited or a quota was hit unannounced.
This post is the practical reference we wish we had when building BulkMD, where the bulk-processing dashboard juggles hundreds of URLs across persistent and transient state. The companion MV3 service-worker post covers the what of state-driven worker design; this post covers the where — which storage primitive holds which slice of state, and why the wrong choice will eventually bite you.
The five storage primitives in 2026
Manifest V3 ships four chrome.storage areas plus IndexedDB as the major options. There is also the Origin Private File System (OPFS) for files, but it is rarely the right fit for an extension's state.
| Storage | Lifetime | Quota | Cross-context | Sync across devices | Rate limit |
|---|---|---|---|---|---|
chrome.storage.session | Browser session | ~10 MB | Yes | No | None published |
chrome.storage.local | Until uninstall | ~10 MB | Yes | No | ~120 writes/min |
chrome.storage.sync | Until uninstall | 100 KB total | Yes | Yes | ~120 writes/min, 8 KB/item |
chrome.storage.managed | Read-only | n/a | Yes | n/a (admin-managed) | n/a |
| IndexedDB | Until uninstall | ~60% of disk | Yes | No | None published |
The "cross-context" column means the value is visible to all extension contexts — service worker, content scripts, popup, options page — without any message-passing. This is the property that makes chrome.storage the default choice for shared state in MV3, since the service worker cannot hold module-level mutable state reliably.
The "rate limit" column is where most surprises live. Both local and sync enforce roughly 120 writes per minute per item, and sync has the additional constraint of 8 KB per item. Exceeding these caps does not throw an error in most cases — it silently drops writes, which produces the worst kind of bug: code that "works" but loses data.
Session is the right default for queue state
For any state that does not need to survive a browser restart, chrome.storage.session is the right primitive. It backs onto the service worker's session-level memory, persists across worker wakes within a session, and has no published rate limit because writes do not hit disk. It is the closest MV3 equivalent of the module-level variable you wanted to use.
A typical pattern for queue-heavy work, from BulkMD's bulk processor:
const QUEUE_KEY = "bulk-queue";
interface QueueState {
pending: string[]; // URLs not yet processed
inflight: number[]; // tab IDs currently extracting
done: { url: string; result: "ok" | "err"; at: number }[];
}
async function getQueue(): Promise<QueueState> {
const { [QUEUE_KEY]: state } = await chrome.storage.session.get(QUEUE_KEY);
return state ?? { pending: [], inflight: [], done: [] };
}
async function withQueue<T>(fn: (s: QueueState) => T): Promise<T> {
const state = await getQueue();
const result = fn(state);
await chrome.storage.session.set({ [QUEUE_KEY]: state });
return result;
}
The withQueue helper is the load-bearing abstraction. Every state mutation goes through it: read the state, mutate in memory, write back. There is no "in-memory cache" because chrome.storage.session is already fast enough — typical reads complete in under a millisecond on a warm worker.
When the user closes Chrome, the session storage is gone. This is usually what you want for an in-progress queue: a half-finished job from yesterday is rarely worth resuming today. If the user does want resume-across-restart, mirror a checkpoint to local on pause or completion (see below).
Local is for persistent settings and checkpoints
chrome.storage.local is the right home for two kinds of data: user settings (concurrency cap, default extraction mode, theme preference) and infrequent checkpoints (the URL list before a run, the final result manifest after a run). It is durable across browser restarts and uninstall-only.
The 120-writes-per-minute cap is the gotcha. A bulk-processing extension that writes its queue to local on every URL completion will hit this cap at roughly two URLs per second, and at that point the storage will start silently dropping writes. The fix is to coalesce: batch up changes in memory (or in session) and flush to local on coarse events — pause, completion, every-30-seconds idle tick.
let pendingFlush: number | null = null;
async function flushCheckpoint() {
if (pendingFlush !== null) return;
pendingFlush = self.setTimeout(async () => {
const state = await getQueue();
await chrome.storage.local.set({ "queue-checkpoint": state });
pendingFlush = null;
}, 5_000); // coalesce writes into at-most-once-per-5s
}
This pattern — fast reads from session, throttled writes to local — is the standard shape for any MV3 extension that processes work at faster than human pace. It scales to thousands of items without hitting the storage rate limit because the rate limit applies to writes that actually land, and the coalescing keeps the landing rate well under the cap.
Sync is for portable user preferences, nothing else
chrome.storage.sync is appealing because it ports a user's preferences across their browsers signed into the same Google account. It is also the most rate-limited of the four areas and has the tightest quota: 100 KB total, 8 KB per item, 120 writes per minute. The use case it actually serves is small: settings, preferences, dark-mode flags, recent-input history.
The mistake to avoid is using sync for anything queue-shaped or progress-shaped. A URL queue of any size will blow past the 8-KB-per-item cap, the per-minute write cap, or both, and the failure mode is silent data loss across the user's devices. Reserve sync for the data you would lose without complaint if it failed — preferences are a fit; in-progress work is not.
A reasonable mental model: if the value is "small, stable, and the user would notice it being wrong on another device," sync fits. If the value is "large, changing fast, or unique to this machine's work," sync does not.
When to graduate to IndexedDB
chrome.storage.local's ~10 MB quota sounds generous until you ship an extension whose users save results. BulkMD's bulk dashboard can hold result manifests for thousand-page runs, each with a few hundred KB of Markdown plus metadata — and we have seen users push the cumulative storage past 30 MB in a single week of heavy use. That is chrome.storage.local-killer territory.
The graduation path is IndexedDB. The API is more verbose than chrome.storage, but the quota is roughly 60% of the user's disk space (gigabytes, not megabytes), there is no published rate limit, and you can index records by key range for efficient querying. The setup cost is real — a typical IndexedDB wrapper is a hundred lines of code, plus migration logic when you change schemas — and you pay it once.
For BulkMD specifically, our storage layout is:
| What | Where | Why |
|---|---|---|
| Current bulk-run queue | chrome.storage.session | Transient, fast, no rate limit |
| User settings (concurrency, mode) | chrome.storage.local | Persistent, infrequent writes |
| Checkpoint snapshot of an active run | chrome.storage.local (coalesced) | Survives restart, small data |
| Completed-run history with Markdown | IndexedDB | Large data, multiple runs accumulating |
This split is what lets us claim "queue survives service-worker restarts" in the store listing while also holding hundreds of MB of historic results without hitting any storage cap.
The two most common storage bugs
After shipping and reviewing dozens of MV3 extensions, two patterns account for most storage-related failures.
The first is the "queue on local" anti-pattern. An extension stores its work queue directly in chrome.storage.local and mutates it on every state transition. For small workloads (under 50 items, completed in under a minute) this works. For anything larger, the 120-writes-per-minute cap silently kicks in, and the queue starts losing items. The fix is the session-plus-coalesced-checkpoint pattern above.
The second is the "sync for everything" anti-pattern. An extension uses chrome.storage.sync as its only storage, on the theory that "it works like local but better." Then a user runs an actual workload, the 8-KB-per-item cap trips, and entire records get rejected. The fix is the disciplined split above: sync for preferences only, local for everything else.
A close third is forgetting that chrome.storage is async. Code that reads a value, mutates it, and writes it back without awaiting each step will race with itself and with other contexts. Every storage operation needs await, and the read-mutate-write sequence needs to be wrapped in a helper that prevents interleaving (the withQueue pattern above does this implicitly by completing atomically within one async function).
TL;DR
Use chrome.storage.session for transient queue state, chrome.storage.local for persistent settings and coarse checkpoints, chrome.storage.sync only for small portable preferences, and IndexedDB when you genuinely need more than 5 MB or efficient range queries. Coalesce writes to local so you stay under the 120-per-minute cap. Treat sync as a luxury rather than a default. The most common production bug — silent queue corruption from rate-limited writes — disappears when you commit to the session-plus-coalesced-checkpoint pattern.
If you want to see this storage architecture shipped in a production Chrome extension, BulkMD is the Chrome Web Store listing — the bulk dashboard's "queue survives Chrome restarts" feature is the patterns above made concrete.
Frequently asked questions
What's the actual upper bound on chrome.storage.local in 2026?
Should I use chrome.storage.session in a content script?
Is there a transactional API for chrome.storage?
Can I use Web Storage (localStorage / sessionStorage) instead?
How do I migrate users from chrome.storage.local to IndexedDB?
About the author
Independent software engineer building developer tools at Soft Web Grove. Creator and maintainer of BulkMD.
Reach the team at [email protected] — typically within 24 hours, any day of the year. Soft Web Grove also takes a small number of outside engagements; details on the about page.