Originally published on Extenify.

Chrome extension storage limits look generous until you read the fine print: chrome.storage.sync advertises roughly 100 KB, yet a single 9 KB settings object gets rejected. The real ceiling is per item, the error is easy to swallow, and the user only notices when their preferences vanish after an update. Here is how the three storage areas actually behave in Manifest V3, and what to do when a write fails.

The numbers that matter

All three areas share the same API, but the quotas are very different. Chrome also exposes them at runtime (for example chrome.storage.sync.QUOTA_BYTES_PER_ITEM), so you can check them in code instead of hardcoding.

sync local session
Total 102,400 bytes 10 MB 10 MB (in memory)
Per item 8,192 bytes none none
Max items 512 none none
Write rate 120/min, 1,800/hour none none
Survives an update yes yes no
Lifted by unlimitedStorage no yes no

A few behaviours worth remembering:

  • sync without a signed-in user falls back to acting like local storage, but the sync quotas still apply. You can't dodge the 8 KB rule by signing out during testing.
  • session is wiped when the extension is disabled, reloaded or updated, and when the browser restarts. Content scripts can't see it unless you change its access level.
  • Uninstalling clears everything. A reinstall starts from zero.
  • localStorage doesn't exist in a service worker, which is why MV3 code ends up on chrome.storage in the first place.

Bytes are not characters

Chrome measures quota as the JSON-serialized value plus the key length, in UTF-8 bytes. Three consequences catch people out:

  1. A string pays 2 extra bytes for its quotes.
  2. Accented letters cost 2 bytes and emoji or CJK text 3 to 4, so non-English users hit the per-item limit with far less text.
  3. Date, Map and Set serialize to {}, and undefined keys disappear. Store dates as ISO strings and collections as arrays.

Don't do the math by hand. Ask Chrome what it will enforce:

const bytes = await chrome.storage.sync.getBytesInUse('prefs'); const pct = Math.round((bytes / chrome.storage.sync.QUOTA_BYTES_PER_ITEM) * 100); console.log(`prefs is at ${pct}% of the per-item limit`); 

What failure looks like

When a write breaks a limit, Chrome stores nothing and rejects the promise. In current Chromium you'll see messages like Resource::kQuotaBytesPerItem quota exceeded, Resource::kQuotaBytes quota exceeded, Resource::kMaxItems quota exceeded, or a MAX_WRITE_OPERATIONS_PER_MINUTE error for sync. Older builds used different wording, so match on the word "quota" rather than the full string.

The real bug is usually an un-awaited set(). The options page says "Saved", the promise rejects in the background, and the setting is gone. Route every sync write through one helper that falls back to local:

export async function persist(key, value) { try { await chrome.storage.sync.set({ [key]: value }); return 'synced'; } catch (err) { if (!/quota/i.test(err.message)) throw err; await chrome.storage.local.set({ [key]: value }); return 'local-only'; } } 

Four ways out of a quota error

  1. Move growing data to local. Rule lists, history and caches don't belong in sync. Migrate them once in chrome.runtime.onInstalled when reason === 'update', and write to local before removing from sync so a stopped service worker never leaves you with zero copies.
  2. Chunk a value that truly must sync. Encode the JSON as UTF-8 bytes, split it into pieces comfortably below 8 KB, and write all chunks plus a count key in one set() call so readers never see half a value. The 100 KB total still caps you at roughly 70 KB of original JSON, which is usually a sign the data should live in local.
  3. Debounce sync writes. A slider that saves on every input event blows through 120 writes a minute fast. Debounce in the popup or options page, not in the service worker, where a pending timer dies with the worker.
  4. Request unlimitedStorage or switch to IndexedDB. The permission removes the local cap (and applies to IndexedDB too) and carries no install warning, so adding it in an update shouldn't prompt users to re-approve. For thousands of records you need to query, IndexedDB is the better tool anyway.

One more tip for big local stores: since Chrome 130, chrome.storage.local.getKeys() lets you read in batches instead of pulling everything with get(null).

Firefox enforces the same sync limits, but needs an add-on ID in browser_specific_settings, syncs about every 10 minutes, and doesn't sync extension data on Android. Edge, being Chromium, matches Chrome.

Test against a real browser

Storage mocks don't enforce quotas, and a dev profile is nearly empty, so these bugs surface months later on the power user with hundreds of rules. Run at least one test in real Chromium that writes 8,193 bytes and asserts a rejection. In production, report a coarse usage bucket (under 10%, 50%, 90% of quota) from getBytesInUse(null) so you see trouble coming.

Key takeaways

  • sync: 100 KB total, 8 KB per item, 512 items, 120 writes per minute. Keep it for a few small preferences.
  • local: 10 MB, unlimited with a warning-free permission. It should hold almost everything.
  • session: 10 MB in memory, gone after every update or restart.
  • Quota is UTF-8 bytes of the JSON plus the key, and a failed write stores nothing.
  • Always await and catch storage writes, and test quotas in real Chromium.

The original post has the measured error strings, a full chunking implementation and an FAQ: read the complete guide to Chrome extension storage limits for sync, local and session. And if storage bugs tend to show up for you as one-star "it forgot my settings" reviews, Extenify's extension analytics and review monitoring can flag rating drops across Chrome, Edge and Firefox the day after a release.

Have you ever shipped an update that quietly lost users' settings, and was it a quota error or something else entirely?