Resolving Concurrent Writes in chrome.storage
Prevent lost updates when the popup, options page, content scripts and service worker write chrome.storage at once: read-modify-write races, single-writer design, per-item keys, Web Locks and version checks.
Table of Contents
A user adds a tag in the popup while the service worker finishes a sync, and one of the two changes vanishes. Or two tabs’ content scripts each increment a counter and the total goes up by one. Nothing errors. chrome.storage has no transactions: every context that reads a value, changes it, and writes it back is racing every other context doing the same. In an extension with a popup, an options page, a side panel, content scripts in dozens of tabs and a service worker, these races are not theoretical. This guide shows how to make writes safe. It belongs to chrome.storage API and sync.
How updates get lost
Each chrome.storage call is atomic on its own: a set writes all its keys together, and a get returns a consistent snapshot. But the common pattern — get an object, modify it in JavaScript, set it back — spans two calls with an await between them. If another context writes the same key in that gap, the second set overwrites it with a value computed from stale data. The more data you pack under one key (a single settings object, a single items array) and the more contexts that write it, the more often this happens. The fixes fall into three families: avoid sharing (one writer, or one key per item), serialise access (a lock), or detect conflicts (a version check and retry).
Step-by-step: safe writes
1. Make the service worker the only writer for shared data
1// any extension page or content script
2export const addTag = (itemId, tag) =>
3 chrome.runtime.sendMessage({ type: "tags:add", itemId, tag });
4
5// sw.js
6chrome.runtime.onMessage.addListener((msg, _s, reply) => {
7 if (msg?.type !== "tags:add") return;
8 enqueueWrite(() => addTagNow(msg.itemId, msg.tag)).then(reply);
9 return true;
10});
Execution context: pages and content scripts send intents; the service worker performs every write. Within one worker, a simple promise queue (step 2) serialises the writes completely. This is the most robust design and the easiest to reason about: one place mutates shared state, everything else reads it and listens to storage.onChanged.
2. Serialise writes inside the worker
1let chain = Promise.resolve();
2export function enqueueWrite(fn) {
3 const run = chain.then(fn, fn); // run even if the previous write failed
4 chain = run.catch(() => {});
5 return run;
6}
7
8async function addTagNow(itemId, tag) {
9 const key = `item:${itemId}`;
10 const { [key]: item } = await chrome.storage.local.get(key);
11 if (!item) return { ok: false };
12 item.tags = [...new Set([...(item.tags ?? []), tag])];
13 await chrome.storage.local.set({ [key]: item });
14 return { ok: true };
15}
Execution context: the service worker. Each queued function starts only after the previous one finishes, so no two read-modify-write cycles overlap. The queue lives in memory and disappears when the worker is evicted — which is fine, because an evicted worker has no writes in flight. If a split-mode incognito instance also writes, use step 4’s lock across instances.
3. Split large objects into per-item keys
1// Before: one key holding everything — every edit rewrites all items
2// { items: { a: {...}, b: {...}, c: {...} } }
3
4// After: one key per item — edits to different items never collide
5// { "item:a": {...}, "item:b": {...}, "item:c": {...}, "itemIndex": ["a","b","c"] }
6
7export async function updateItem(id, patch) {
8 const key = `item:${id}`;
9 const { [key]: cur = {} } = await chrome.storage.local.get(key);
10 await chrome.storage.local.set({ [key]: { ...cur, ...patch, updatedAt: Date.now() } });
11}
Execution context: any trusted context. Smaller keys shrink the window for conflicts — two contexts must now edit the same item at the same moment — and make writes cheaper, since each set serialises one item instead of the whole collection. Keep an index key only if you need ordering, and update it only when items are added or removed. For chrome.storage.sync, per-item keys also keep you under the 8 KB-per-item limit.
4. Use Web Locks when several contexts must write
1export async function withLock(name, fn) {
2 return navigator.locks.request(`storage:${name}`, { mode: "exclusive" }, fn);
3}
4
5// options page and service worker both use it
6await withLock("settings", async () => {
7 const { settings = {} } = await chrome.storage.local.get("settings");
8 settings.theme = "dark";
9 await chrome.storage.local.set({ settings });
10});
Execution context: the service worker and extension pages, which share the extension origin and therefore share Web Locks. While one context holds storage:settings, others wait. Content scripts run on the page’s origin and cannot take the extension’s locks — route their writes through the worker. Locks are released automatically if the holding context is closed or terminated, so a popup closing mid-write does not deadlock the extension.
5. Detect conflicts with a version field
1export async function casUpdate(key, mutate, attempts = 5) {
2 for (let i = 0; i < attempts; i++) {
3 const { [key]: cur = { v: 0 } } = await chrome.storage.local.get(key);
4 const next = { ...mutate(structuredClone(cur)), v: cur.v + 1 };
5 await chrome.storage.local.set({ [key]: next });
6 const { [key]: check } = await chrome.storage.local.get(key);
7 if (check.v === next.v && check.writer === next.writer) return next;
8 await new Promise((r) => setTimeout(r, 10 * 2 ** i));
9 }
10 throw new Error(`could not update ${key}`);
11}
Execution context: any context, including those without locks. This is optimistic concurrency: write, re-read, and retry if someone else’s write landed. Set a unique writer id in mutate so the check can tell your write from a concurrent one with the same version number. It narrows the race but cannot eliminate it without a true compare-and-set primitive, which chrome.storage lacks — use it where locks are unavailable and conflicts are rare.
6. Merge instead of overwrite for sets and counters
1// Counters: store per-context increments, sum on read
2await chrome.storage.local.set({ [`count:${contextId}`]: myCount });
3const all = await chrome.storage.local.get(null);
4const total = Object.entries(all).filter(([k]) => k.startsWith("count:")).reduce((s, [, v]) => s + v, 0);
Execution context: any context. Some data structures avoid conflicts by design: a counter split into per-writer shards cannot lose increments, and a set represented as one key per member cannot lose additions. These conflict-free shapes are worth reaching for when many content scripts contribute to one total.
Common mistakes
- One giant key. Every write rewrites everything and collides with everything.
- Writes from content scripts in many tabs. Funnel them through the worker.
- Assuming
awaitmakes it safe. Awaiting thegetis exactly where the race happens. - Locks in content scripts. They take the page’s locks, not the extension’s.
- Retry without backoff. Contended version checks spin and make contention worse.
Cross-browser variation
- Chrome / Edge: each
setis atomic; Web Locks available in the service worker and pages. - Firefox: same atomicity; Web Locks supported in extension pages and background. Firefox’s event page makes a single in-memory queue slightly longer-lived, which helps.
- Safari: Web Locks supported in recent versions; feature-detect
navigator.locksand fall back to the single-writer pattern.
Verification
- Write a test that fires 100 concurrent
addTagcalls from two extension pages and asserts all 100 tags are present. - Repeat with the naive read-modify-write and confirm it loses tags — the test proves the fix matters.
- Close a page mid-write under a lock and confirm another context acquires the lock promptly.
- Inspect storage after a busy session: per-item keys, no oversized single key.
FAQ
Does chrome.storage.set merge objects?
No. It replaces each key’s value entirely. Merging is your job, which is where races come from.
Are storage.sync writes from two devices a concurrency problem too?
Yes, at a coarser grain: the last write synced wins per key. Per-item keys reduce the damage; see syncing options across a user’s devices.
Should I move to IndexedDB for transactions?
If your data is relational or large, yes — IndexedDB transactions are atomic across stores. For small settings, the patterns above are enough.
Related
- Batching storage writes to stay under quota — fewer writes, fewer races.
- Storage onChanged listener patterns — reacting to the single writer’s updates.
- Contract testing a storage schema — keeping keys and shapes consistent.
- chrome.storage API and sync — the parent topic.