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.
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.
Extension writes to chrome.storage.sync can fail with cryptic error strings, drop silently, or produce partial writes that corrupt your data model. The three distinct quota limits are easy to conflate, and the fallback strategy is non-obvious when you first encounter them. This page walks through classifying each error type, chunking large values, retrying after rate-limit rejections, and falling back to storage.local gracefully — without leaving your storage in an inconsistent state. For broader context on sync versus local trade-offs, see the chrome.storage.sync overview.
chrome.storage.sync enforces three independent hard limits at the extension host level, not in your script:
When any limit is breached, chrome.storage.sync.set() rejects its Promise with an error whose .message contains one of those three strings verbatim. They are not error codes or constants you can import — they are plain substrings you must match at runtime.
MV3 service workers compound the problem. Because a service worker can be spun up, do a burst of writes during initialization, and be evicted before retries complete, rate-limit errors are especially common at startup. The ASCII flow below shows where each limit fires:
Extension code
│
▼
chrome.storage.sync.set({ key: value })
│
├─── serialized size of key+value > 8 KB? → QUOTA_BYTES_PER_ITEM error
│
├─── total sync store size > 100 KB? → QUOTA_BYTES error
│
└─── write rate > ~120/min? → MAX_WRITE_OPERATIONS_PER_MINUTE error
All three are detectable synchronously in a .catch() handler or try/catch around an await. None of them are retried automatically by the browser.
The first defensive layer is a classifier that tells you which quota was breached so you can apply the right recovery strategy.
1// service-worker.ts
2
3const QUOTA_ERROR = {
4 BYTES: "QUOTA_BYTES",
5 BYTES_PER_ITEM: "QUOTA_BYTES_PER_ITEM",
6 RATE: "MAX_WRITE_OPERATIONS_PER_MINUTE",
7} as const;
8
9type QuotaErrorKind = "bytes" | "bytesPerItem" | "rate" | "unknown";
10
11function classifyStorageError(err: unknown): QuotaErrorKind {
12 const msg = err instanceof Error ? err.message : String(err);
13 if (msg.includes(QUOTA_ERROR.BYTES_PER_ITEM)) return "bytesPerItem";
14 if (msg.includes(QUOTA_ERROR.BYTES)) return "bytes";
15 if (msg.includes(QUOTA_ERROR.RATE)) return "rate";
16 return "unknown";
17}
18
19async function safeSet(
20 key: string,
21 value: unknown
22): Promise<{ ok: boolean; kind: QuotaErrorKind | null }> {
23 try {
24 await chrome.storage.sync.set({ [key]: value });
25 return { ok: true, kind: null };
26 } catch (err) {
27 const kind = classifyStorageError(err);
28 console.warn(`[storage] write failed — quota kind: ${kind}`, err);
29 return { ok: false, kind };
30 }
31}
Execution context: MV3 service worker and extension pages. chrome.storage is available in both; browser.storage works identically in Firefox with the WebExtensions polyfill. Check the error string before branching — misidentifying a QUOTA_BYTES_PER_ITEM error as a generic QUOTA_BYTES error leads to the wrong recovery path.
When a single value exceeds the 8 KB per-item limit, split it across numbered keys. A safe chunk size is 7,500 bytes — this provides a buffer for the key name overhead and JSON escaping.
1const CHUNK_SIZE = 7_500; // bytes, leaving headroom below the 8 KB limit
2const CHUNK_SEPARATOR = "__chunk__";
3
4function encodeChunks(key: string, value: unknown): Record<string, string> {
5 const serialized = JSON.stringify(value);
6 const chunks: Record<string, string> = {};
7 let i = 0;
8 let offset = 0;
9
10 while (offset < serialized.length) {
11 // slice by character; for ASCII-heavy JSON this approximates byte count
12 chunks[`${key}${CHUNK_SEPARATOR}${i}`] = serialized.slice(
13 offset,
14 offset + CHUNK_SIZE
15 );
16 offset += CHUNK_SIZE;
17 i++;
18 }
19
20 // manifest key records total chunk count for safe reassembly
21 chunks[`${key}${CHUNK_SEPARATOR}meta`] = JSON.stringify({ count: i });
22 return chunks;
23}
24
25async function setChunked(key: string, value: unknown): Promise<void> {
26 const chunks = encodeChunks(key, value);
27 // Write all chunks in one set() call to minimize write-operation count
28 await chrome.storage.sync.set(chunks);
29}
30
31async function getChunked(key: string): Promise<unknown | null> {
32 const metaKey = `${key}${CHUNK_SEPARATOR}meta`;
33 const metaResult = await chrome.storage.sync.get(metaKey);
34 const meta = metaResult[metaKey];
35 if (!meta) return null;
36
37 const { count } = JSON.parse(meta) as { count: number };
38 const chunkKeys = Array.from(
39 { length: count },
40 (_, i) => `${key}${CHUNK_SEPARATOR}${i}`
41 );
42 const result = await chrome.storage.sync.get(chunkKeys);
43
44 const ordered = chunkKeys.map((k) => result[k] ?? "");
45 if (ordered.some((c) => c === "")) {
46 console.error(`[storage] incomplete chunks for key "${key}" — possible partial write`);
47 return null;
48 }
49
50 return JSON.parse(ordered.join(""));
51}
Execution context: MV3 service worker. The single chrome.storage.sync.set(chunks) call is critical — writing each chunk in a separate call would burn multiple write operations toward the rate limit and leave a window for partial failure between calls. Firefox and Chrome both honour the single-call atomicity for set().
Rate-limit rejections are transient. An in-memory retry with jitter handles them well for values already held in the service worker’s memory. Because MV3 service workers can be evicted, this strategy is only reliable for retries that complete within the current service-worker lifetime (typically a few seconds of backoff is safe).
1interface RetryOptions {
2 maxAttempts?: number;
3 baseDelayMs?: number;
4}
5
6async function retryWithBackoff<T>(
7 fn: () => Promise<T>,
8 { maxAttempts = 4, baseDelayMs = 500 }: RetryOptions = {}
9): Promise<T> {
10 for (let attempt = 0; attempt < maxAttempts; attempt++) {
11 try {
12 return await fn();
13 } catch (err) {
14 const kind = classifyStorageError(err);
15 if (kind !== "rate" || attempt === maxAttempts - 1) throw err;
16
17 // Exponential backoff with ±25 % jitter
18 const delay =
19 baseDelayMs * 2 ** attempt * (0.75 + Math.random() * 0.5);
20 console.info(`[storage] rate limited, retrying in ${Math.round(delay)} ms`);
21 await new Promise<void>((resolve) => setTimeout(resolve, delay));
22 }
23 }
24 // TypeScript path exhaustion — never reached
25 throw new Error("retryWithBackoff: exhausted attempts");
26}
27
28// Usage inside the service worker
29chrome.runtime.onInstalled.addListener(async () => {
30 await retryWithBackoff(() =>
31 chrome.storage.sync.set({ lastInstall: Date.now() })
32 );
33});
Execution context: MV3 service worker. setTimeout is available in service workers and is appropriate here — these are short delays (under ~30 seconds) for values already in memory. For retries that must survive service-worker eviction, persist the pending write to chrome.storage.local first and schedule a chrome.alarms callback to re-attempt it.
When the total 100 KB sync budget is exhausted, fall back to storage.local and track which keys are local-only so you can re-promote them later.
1const LOCAL_FALLBACK_INDEX_KEY = "__sync_fallback_keys__";
2
3async function safeSyncSet(key: string, value: unknown): Promise<void> {
4 const result = await safeSet(key, value);
5
6 if (result.ok) {
7 // If this key was previously demoted, remove it from the fallback index
8 const { [LOCAL_FALLBACK_INDEX_KEY]: raw } =
9 await chrome.storage.local.get(LOCAL_FALLBACK_INDEX_KEY);
10 const index: string[] = raw ?? [];
11 const updated = index.filter((k) => k !== key);
12 if (updated.length !== index.length) {
13 await chrome.storage.local.set({ [LOCAL_FALLBACK_INDEX_KEY]: updated });
14 }
15 return;
16 }
17
18 if (result.kind === "bytes") {
19 console.warn(`[storage] sync quota full — writing "${key}" to local storage`);
20 await chrome.storage.local.set({ [key]: value });
21
22 // Record that this key lives in local storage
23 const { [LOCAL_FALLBACK_INDEX_KEY]: raw } =
24 await chrome.storage.local.get(LOCAL_FALLBACK_INDEX_KEY);
25 const index: string[] = raw ?? [];
26 if (!index.includes(key)) {
27 await chrome.storage.local.set({
28 [LOCAL_FALLBACK_INDEX_KEY]: [...index, key],
29 });
30 }
31 return;
32 }
33
34 // For per-item or rate errors, let the caller handle via chunking / retry
35 throw new Error(`Unhandled storage error kind: ${result.kind}`);
36}
37
38async function getWithFallback(key: string): Promise<unknown> {
39 const { [LOCAL_FALLBACK_INDEX_KEY]: raw } =
40 await chrome.storage.local.get(LOCAL_FALLBACK_INDEX_KEY);
41 const fallbackKeys: string[] = raw ?? [];
42
43 if (fallbackKeys.includes(key)) {
44 const local = await chrome.storage.local.get(key);
45 return local[key];
46 }
47
48 const synced = await chrome.storage.sync.get(key);
49 return synced[key];
50}
Execution context: MV3 service worker and popup/options pages. The fallback index itself is stored in storage.local (no quota concern there — local storage allows up to 10 MB by default). Firefox and Chrome behave identically here. Safari requires extra care: see the cross-browser section below.
QUOTA_BYTES, QUOTA_BYTES_PER_ITEM, MAX_WRITE_OPERATIONS_PER_MINUTE) are thrown exactly as documented. getBytesInUse() returns an accurate count you can check proactively.browser.storage.sync with identical error strings. The total sync quota is significantly higher (Firefox Sync allows up to 8 MB per extension as of mid-2024), but the 8 KB per-item limit still applies. The rate limit is also enforced but at different thresholds — do not assume Firefox is immune to rate errors.chrome.storage.sync.getBytesInUse(null) proactively before writing, and compare against chrome.storage.sync.QUOTA_BYTES, rather than relying on a caught error.chrome://extensions, find your extension, and click Inspect views: service worker to open its DevTools console.1chrome.storage.sync.getBytesInUse(null, (bytes) => console.log('sync bytes in use:', bytes));
await chrome.storage.sync.getBytesInUse(null)) requires Chrome 95+.*1// Write a value larger than 8 KB
2const big = { data: 'x'.repeat(9_000) };
3chrome.storage.sync.set({ testKey: big }).catch(console.error);
4// Expected: Error: QUOTA_BYTES_PER_ITEM
"bytesPerItem" and the chunked path is taken for that key.sync and local buckets and confirm fallback keys are recorded in __sync_fallback_keys__.No. getBytesInUse() is a read operation and does not count against MAX_WRITE_OPERATIONS_PER_MINUTE. Reads are not rate-limited by the extension host. You can call it freely before every write to guard against quota overflow proactively.
The already-written chunk keys remain in sync storage, but the manifest key (key__chunk__meta) may be absent or stale, causing getChunked() to return null or partially reassembled data. To guard against this, always write all chunks and the manifest key in a single chrome.storage.sync.set() call (as shown in Step 2). If you need transactional guarantees, write the manifest key last in a separate call and treat its absence as a signal that reassembly should be skipped until the write completes cleanly.
No. The unlimitedStorage manifest permission raises the quota for chrome.storage.local only (from 10 MB to unlimited disk space). chrome.storage.sync limits are fixed by the browser vendor and cannot be extended by any permission.
Your development profile likely has a fresh, nearly empty sync store. Real users may have had your extension installed for months — or have several other extensions also writing to the shared 100 KB sync budget. Always test quota handling against a profile with realistic data, and consider logging getBytesInUse() to your analytics on startup so you can track how close to the limit real users are.
Chrome vs Firefox vs Safari Manifest V3 API compatibility reference — namespace differences, Promise vs callback, storage quotas, declarativeNetRequest, and the webextension-polyfill.
Use chrome.scripting in Manifest V3 to dynamically inject JavaScript and CSS — executeScript, registerContentScripts, world isolation, and activeTab patterns.
Persist and synchronise extension state across devices with chrome.storage.sync — quotas, async patterns, change events, encryption and cross-browser adapters for Manifest V3.
Master chrome.declarativeNetRequest static, dynamic, and session rulesets in MV3 — rule schema, modifyHeaders, redirect, block actions, quota limits, and getMatchedRules debugging.