Measuring Storage Read and Write Latency
Benchmark chrome.storage and IndexedDB from inside an MV3 extension — reliable timing in the worker and pages, value size effects, cold versus warm reads, and finding the storage call on your hot path.
Table of Contents
“Storage is slow” is one of the most common performance assumptions in extension code, and it is usually wrong in the specific: a single small chrome.storage.local.get is a few milliseconds, which is nothing — until it is called inside a loop, or reads a 5 MB value to use one field, or sits in front of the popup’s first paint. The only way to know which is to measure, in the context where it runs, with values shaped like your real data. This guide is part of performance profiling and optimisation.
What dominates storage latency
Step-by-step
1. Time with performance.now, many times
1async function bench(label, fn, runs = 50) {
2 await fn(); // warm-up, not measured
3 const times = [];
4 for (let i = 0; i < runs; i++) {
5 const t0 = performance.now();
6 await fn();
7 times.push(performance.now() - t0);
8 }
9 times.sort((a, b) => a - b);
10 const p = (q) => times[Math.floor(q * (times.length - 1))];
11 console.table({ [label]: { p50: p(0.5).toFixed(2), p95: p(0.95).toFixed(2), max: times.at(-1).toFixed(2) } });
12}
Execution context: any extension context — the worker, a popup, an options page. performance.now is monotonic and sub-millisecond; a single measurement is noise, so take the median and the 95th percentile of many. Discarding the first run separates the one-time cost from the steady state.
2. Benchmark with realistic values
1const kb = (n) => "x".repeat(n * 1024);
2
3await chrome.storage.local.set({ small: kb(1), medium: kb(100), large: kb(1024) });
4
5await bench("get small", () => chrome.storage.local.get("small"));
6await bench("get medium", () => chrome.storage.local.get("medium"));
7await bench("get large", () => chrome.storage.local.get("large"));
8await bench("set medium", () => chrome.storage.local.set({ medium: kb(100) }));
Execution context: the service worker console, or a dedicated benchmark page. Strings are a reasonable proxy for serialisation cost; for a truer picture, use a snapshot of your own stored data shape — deeply nested objects deserialise more slowly than one flat string of the same size.
3. Compare storage areas and IndexedDB
1await bench("local get 100KB", () => chrome.storage.local.get("medium"));
2await bench("session get 100KB", () => chrome.storage.session.get("medium"));
3await bench("sync get 1KB", () => chrome.storage.sync.get("small"));
4await bench("idb get 100KB", () => tx("bench", "readonly", (s) => s.get("medium")));
Execution context: the service worker. storage.session is in memory and usually fastest; storage.sync reads come from a local mirror and are comparable to local for small values. IndexedDB has higher per-call overhead but wins decisively when you need one record out of many, because it does not deserialise the rest — the trade-off discussed in choosing between chrome.storage and IndexedDB.
4. Measure cold, not just warm
The first storage call after a worker cold start is slower than steady state, and it is the one users feel.
1// At the very top of the worker, for a development build
2const t0 = performance.now();
3chrome.storage.local.get("settings").then(() => {
4 console.debug(`[perf] first storage read ${(performance.now() - t0).toFixed(1)} ms after worker start`);
5});
Execution context: the top of the service worker module. Stop the worker from chrome://extensions, trigger an event, and read the logged number. Comparing it with the warm median shows how much of a cold start is spent waiting on storage — part of the picture in reducing service worker cold start latency.
5. Find storage calls on the hot path
A cheap call becomes expensive when it runs per item. Trace the real code rather than guessing.
1// A thin wrapper used only in development builds
2const origGet = chrome.storage.local.get.bind(chrome.storage.local);
3chrome.storage.local.get = async (...args) => {
4 const t0 = performance.now();
5 const r = await origGet(...args);
6 const ms = performance.now() - t0;
7 if (ms > 5) console.warn(`[perf] slow storage.get ${JSON.stringify(args[0])} ${ms.toFixed(1)} ms`, new Error().stack);
8 return r;
9};
Execution context: the top of a development build’s worker or page. Every slow read logs its key and a stack, which points directly at the loop or render path responsible. Remove it from release builds — patching chrome.* in shipped code is the kind of thing a reviewer will question.
Common findings and their fixes
Most storage performance problems in extensions fall into four patterns, and the fixes are structural rather than clever.
The god key. Everything lives under one state key, so reading a single setting deserialises the whole application’s data. Split by access pattern: small, frequently read values in their own keys; large collections in IndexedDB.
Reads in a loop. A render function that calls storage.get per list item turns one fast read into hundreds. Read once, outside the loop, and pass the data in.
Re-reading on every event. A worker handler that reads settings from storage on every message pays the cost on every call. Memoise the promise for the worker’s lifetime, and invalidate it from a storage.onChanged listener.
1let settingsPromise = null;
2export const settings = () => (settingsPromise ??= chrome.storage.sync.get("settings").then((r) => r.settings ?? {}));
3chrome.storage.onChanged.addListener((c, area) => { if (area === "sync" && c.settings) settingsPromise = null; });
Execution context: the service worker. One read per worker lifetime, refreshed only when the value actually changes.
Writing on every keystroke. The write-side version of the loop problem — covered in batching storage writes to stay under quota.
Cross-browser variation
- Chrome / Edge:
storage.localis backed by a per-extension LevelDB;storage.sessionis in memory with a ~10 MB cap. Values are serialised as JSON-compatible data, so deserialisation cost scales with structure as well as size. - Firefox:
browser.storage.localis backed by IndexedDB internally and supports structured-clone values; performance characteristics are similar for typical sizes. - Safari: storage calls have higher overhead, and the background context is colder more often, so the first-read cost matters more. Benchmark on Safari specifically if it is a target.
- All three: measure in the context where the code runs. A popup-context benchmark does not tell you what the worker’s cold-start read costs.
Verification
- Run the benchmark in the worker and record p50 and p95 for your real value sizes.
- Enable the slow-read wrapper, use the extension normally for a few minutes, and review any warnings with their stacks.
- After a fix, re-run the benchmark or the instrumented path and confirm the improvement:
1await bench("popup summary read", () => chrome.storage.session.get("summary"));
2// p50 1.2 ms, p95 2.0 ms — was 41 ms reading the full "state" key
Execution context: the popup’s console. Keeping the before-and-after numbers in the pull request makes the change reviewable.
- Confirm the wrapper is absent from the release build.
FAQ
Is storage.session always faster?
For reads, usually — it is in memory. But it is capped and cleared on browser restart, so it suits caches and derived summaries, not primary data.
Does getBytesInUse tell me about performance?
It tells you size, which predicts read cost for single large keys. It says nothing about how often a key is read — the instrumentation does that.
Should I cache everything in memory in the worker?
Cache what is read often and changes rarely, and invalidate on onChanged. Remember the cache is lost on every eviction, so it only helps within a worker lifetime.
How much storage latency is acceptable on the popup’s first paint?
Aim for the first read to complete within one frame — around 16 ms — so the popup paints real data immediately. That usually means a small, pre-shaped summary key rather than the full dataset, as described in the popup loading guides.
Related
- Local vs sync storage performance comparison — measured differences between the areas.
- Choosing between chrome.storage and IndexedDB — when to change stores.
- Loading popup data without a flash of empty UI — the read that users feel most.
- Performance profiling and optimisation — the parent guide.