Batching Storage Writes to Stay Under Quota

chrome.storage.sync enforces per-minute and per-hour write limits. Coalesce writes behind a queue, collapse repeated keys, and recover cleanly when a write is rejected.

Published September 18, 2026 Updated September 18, 2026 9 min read
Table of Contents

A settings page that writes on every keystroke works perfectly during development and starts rejecting writes the moment a real user drags a slider. chrome.storage.sync caps you at 120 write operations per minute and 1,800 per hour; chrome.storage.local has no rate cap but will happily thrash the disk if you let it. The fix is the same in both cases: never write straight from an event handler. This guide is part of chrome.storage API and sync.

What counts as a write operation

One set call counts as one operation regardless of how many keys it carries — a fact that makes batching unusually effective. Writing ten keys individually costs ten operations; writing the same ten keys in a single object costs one. The per-item size cap (8 KB on sync) still applies to each key separately, and the total cap (100 KB on sync) to everything at once.

Operations consumed writing ten settingsFour strategies for persisting ten changed settings, measured in chrome.storage.sync write operations against the 120-per-minute allowance.set() per keystroke94 opstyping in three fieldsset() per changed field10 opsOne set() per 500 ms flush3 opsOne set() on blur / close1 opsall ten keys, one call
The allowance is counted in calls, not bytes — which makes coalescing the cheapest optimisation available.

Step-by-step: a coalescing write queue

1. Buffer in memory, flush on a timer

The queue holds the newest value for each key. Repeated writes to the same key collapse, which is what makes a slider cheap.

 1// storage-queue.js
 2const pending = new Map();
 3let flushTimer = null;
 4const FLUSH_MS = 500;
 5
 6export function queueWrite(key, value, area = "sync") {
 7  pending.set(`${area}:${key}`, { area, key, value });
 8  if (flushTimer) return;
 9  flushTimer = setTimeout(flush, FLUSH_MS);
10}
11
12async function flush() {
13  flushTimer = null;
14  const byArea = new Map();
15  for (const { area, key, value } of pending.values()) {
16    if (!byArea.has(area)) byArea.set(area, {});
17    byArea.get(area)[key] = value;
18  }
19  pending.clear();
20
21  for (const [area, obj] of byArea) {
22    await chrome.storage[area].set(obj);
23  }
24}

Execution context: an extension page — options, popup or side panel — where a setTimeout is safe because the page owns its own event loop. In a service worker this exact code is unsafe; see step 4.

2. Flush before the surface disappears

A popup can close mid-debounce and take the buffer with it. visibilitychange and pagehide are the last reliable moments to persist.

1addEventListener("pagehide", () => { if (pending.size) flush(); });
2document.addEventListener("visibilitychange", () => {
3  if (document.visibilityState === "hidden" && pending.size) flush();
4});

Execution context: an extension page. pagehide fires when a popup is dismissed; the browser does not wait for the returned promise, so keep the final flush to a single set call. Safari fires pagehide for extension popups as well, but under memory pressure it may skip visibilitychange.

3. Handle rejection rather than assuming success

When the cap is hit, set rejects with MAX_WRITE_OPERATIONS_PER_MINUTE quota exceeded. Treat it as backpressure, not a bug: put the batch back and retry after the minute rolls.

 1async function flushWithRetry(area, obj, attempt = 0) {
 2  try {
 3    await chrome.storage[area].set(obj);
 4  } catch (err) {
 5    const overQuota = /quota|MAX_WRITE/i.test(String(err?.message ?? err));
 6    if (!overQuota || attempt >= 3) throw err;
 7    const waitMs = 5_000 * 2 ** attempt;
 8    await new Promise((r) => setTimeout(r, waitMs));
 9    return flushWithRetry(area, obj, attempt + 1);
10  }
11}

Execution context: an extension page. In Chrome the failure surfaces as a rejected promise; in Firefox the message text differs, which is why the check matches loosely rather than on an exact string. Size-related failures are covered separately in handling storage quota exceeded errors.

4. Do it differently in the service worker

A worker can be evicted between the queueWrite and the flush, losing the buffer with no warning. Inside a worker, either write immediately, or persist the buffer itself to chrome.storage.session and flush on an alarm.

 1// In the service worker: buffer durably, flush on a tick.
 2export async function queueDurable(key, value) {
 3  const { _wq = {} } = await chrome.storage.session.get("_wq");
 4  _wq[key] = value;
 5  await chrome.storage.session.set({ _wq });
 6  await chrome.alarms.create("wq-flush", { delayInMinutes: 1 });
 7}
 8
 9chrome.alarms.onAlarm.addListener(async (a) => {
10  if (a.name !== "wq-flush") return;
11  const { _wq = {} } = await chrome.storage.session.get("_wq");
12  if (!Object.keys(_wq).length) return;
13  await chrome.storage.sync.set(_wq);
14  await chrome.storage.session.remove("_wq");
15});

Execution context: the service worker. chrome.storage.session is in-memory and survives eviction of the worker but not a browser restart — the right durability for a write buffer. The alarm minimum of one minute makes this pattern suitable for background coalescing, not for interactive feedback.

5. Split what does not need to sync

Most of the pressure on sync comes from data that never needed to leave the machine. A window position, a scroll offset, a collapsed-panel flag — all of that belongs in local, which has no write-rate cap.

Which area a value belongs inA decision tree separating values that must follow the user across devices from machine-local UI state and ephemeral session data.Must this value follow the user to another machine?Yesstorage.sync120 writes/min, 8 KB per itemCoalesce writesone set per flushNo, but keep itstorage.localno write-rate capStill batch on hot pathsdisk I/O is not freeNo, this session onlystorage.sessionin-memory, ~10 MBCleared on browser exitperfect for buffers
Moving machine-local state out of sync is usually a bigger win than any amount of debouncing.

Measuring the pressure before you feel it

The quota failure arrives all at once: everything works, then a burst of user activity crosses 120 writes in a minute and every subsequent set rejects for the rest of that window. Because the counter is a rolling minute you cannot see how close you are from the outside — so instrument it.

1// A rolling window counter, kept in memory in whichever context writes.
2const writes = [];
3
4function recordWrite() {
5  const now = Date.now();
6  writes.push(now);
7  while (writes.length && now - writes[0] > 60_000) writes.shift();
8  return writes.length;
9}

Execution context: the extension page or worker that owns the queue. An in-memory counter is enough because the quota is per-extension and the queue is the only writer — if two contexts write independently, you have a design problem the counter will not fix.

Wire it into the flush and log when the count crosses a threshold you set well below the limit. Sixty writes a minute is a comfortable alarm: it leaves half the budget and it fires long before a user notices anything.

The second measurement worth taking is size, which is a separate limit with a separate failure. getBytesInUse accepts a key, a list, or null for everything:

1const perKey = Object.fromEntries(
2  await Promise.all(Object.keys(await chrome.storage.sync.get(null))
3    .map(async (k) => [k, await chrome.storage.sync.getBytesInUse(k)])));

Execution context: the options page or the worker. On sync the per-item cap is 8 KB and the total 100 KB, so a single key creeping past a few kilobytes is the thing to catch — usually an array that started as a list of three and is now a list of three hundred.

The pattern that causes most quota incidents is not a hot slider; it is a storage.onChanged listener that writes back. Two contexts each reacting to the other’s change produce a write loop that saturates the quota in seconds and is invisible in code review because each half looks reasonable.

1// Guard: ignore changes this context caused.
2let selfWrite = false;
3chrome.storage.onChanged.addListener((changes, area) => {
4  if (selfWrite) { selfWrite = false; return; }
5  applyRemote(changes);
6});

Execution context: any context with both a listener and a writer. The flag is crude but effective; the alternative — comparing values before writing — is more robust and is worth the extra code when three or more surfaces share a key.

The write loop that exhausts a quota in secondsTwo extension pages each listening to storage.onChanged and writing in response, producing an unbounded ping-pong of writes.Options pagechrome.storageSide panelQuota counterset({theme:'dark'})onChanged firesset({theme:'dark'}) againechoonChanged firesset(…) again120 writes in ~4 s
Each half looks correct in isolation — the loop only exists once both are running.

Cross-browser variation

  • Chrome / Edge: MAX_WRITE_OPERATIONS_PER_MINUTE is 120 and MAX_WRITE_OPERATIONS_PER_HOUR is 1,800; both are readable as constants on chrome.storage.sync. Exceeding them rejects the promise and leaves prior writes intact.
  • Firefox: browser.storage.sync enforces comparable limits but does not expose the same constants, and the rejection message differs. Firefox’s sync backend also batches server-side, so the client-side symptom appears slightly later.
  • Safari: storage.sync is backed by iCloud and is markedly slower to settle; writes can appear to succeed locally and reconcile minutes later. Keep sync payloads small and never treat a resolved set as confirmation that another device has the value.
  • All three: storage.local has no per-minute write cap, but unlimitedStorage is still needed to exceed the default local quota.

Verification

  1. From the options page console, hammer the queue and confirm only one call reaches storage:
1for (let i = 0; i < 50; i++) queueWrite("volume", i);
2// ~500 ms later, exactly one storage.onChanged event with volume: 49

Execution context: the options page console. Watch the flush with a chrome.storage.onChanged listener — one event means the coalescing worked, fifty means the queue was bypassed.

  1. Call chrome.storage.sync.getBytesInUse(null) before and after a flush and confirm the byte delta matches what you expected to write.
  2. Force the failure path by writing in a tight loop without the queue; you should see the quota rejection within a few seconds, and your retry path should recover without losing the buffered values.

FAQ

Does get count against the quota?

No. Only write operations — set, remove and clear — are counted. Reads are unlimited, which is why keeping a read-through cache in memory is rarely worth the complexity.

Is one set with fifty keys really one operation?

Yes for the per-minute and per-hour counters. The per-item size limit still applies to each key individually, and the whole call fails if any single key exceeds it, so validate sizes before batching large values.

What happens to writes already in the buffer if the extension updates?

They are lost — an update tears down every context. Flush on pagehide in pages, and use the durable storage.session buffer in the worker, so the window where data lives only in memory is as short as possible.

Other Core APIs & Cross-Browser Data Management Resources