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.

Published June 19, 2026 Updated June 19, 2026 10 min read
Table of Contents

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.

Root Cause

chrome.storage.sync enforces three independent hard limits at the extension host level, not in your script:

  • QUOTA_BYTES — 100 KB total across every key in the sync store for one extension.
  • QUOTA_BYTES_PER_ITEM — 8 KB per individual key (serialized value + key name).
  • MAX_WRITE_OPERATIONS_PER_MINUTE — approximately 120 writes per minute, 1,800 per hour.

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.

Step-by-Step Solution

Step 1 — Detect and Classify the Error

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.

Step 2 — Chunk Large Values to Avoid QUOTA_BYTES_PER_ITEM

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().

Step 3 — Retry with Exponential Backoff for Rate-Limit Errors

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.

Step 4 — Fallback to storage.local When Sync Quota Is Full

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.

Cross-Browser Variation

  • Chrome — All three error strings (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.
  • Firefox — Uses 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.
  • Safari — Does not throw on quota overflow; iCloud-backed sync writes are silently dropped when the budget is exceeded. You must call chrome.storage.sync.getBytesInUse(null) proactively before writing, and compare against chrome.storage.sync.QUOTA_BYTES, rather than relying on a caught error.

Verification

  1. Open chrome://extensions, find your extension, and click Inspect views: service worker to open its DevTools console.
  2. In the console, check current sync usage:
    1chrome.storage.sync.getBytesInUse(null, (bytes) => console.log('sync bytes in use:', bytes));
    
    Execution context: DevTools console injected into the service worker context. The callback form works in all Chrome versions; the Promise form (await chrome.storage.sync.getBytesInUse(null)) requires Chrome 95+.*
  3. Trigger a per-item overflow deliberately:
    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
    
    Execution context: DevTools console, service worker.*
  4. Confirm the classifier returns "bytesPerItem" and the chunked path is taken for that key.
  5. In DevTools, open Application → Storage → Extension Storage to inspect both sync and local buckets and confirm fallback keys are recorded in __sync_fallback_keys__.

FAQ

Does getBytesInUse() count as a write operation toward the rate limit?

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.

What happens if a chunked write partially fails mid-chunk?

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.

Can I use the unlimitedStorage permission to raise sync limits?

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.

Why does my extension only hit quota in production, not during development?

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.

Other Core APIs & Cross-Browser Data Management Resources