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.

Published September 18, 2026 Updated September 18, 2026 7 min read
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

Read latency by value size (chrome.storage.local, warm)Median time for a single get of values of 1 KB, 100 KB, 1 MB and 5 MB from chrome.storage.local in a service worker.1 KB value2 ms100 KB value6 ms1 MB value38 ms5 MB value190 msread to use one field
The call overhead is small; the size of what you read — and must deserialise — is what grows.

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.

From a suspicion to a specific fixBenchmark storage in isolation with realistic values, instrument the real code to find slow calls on the hot path, then fix by reading less, caching, or moving to IndexedDB.Benchmarkp50 / p95 per sizeInstrument real codewarn over 5 msStack points at the callerloop or render paththen choose the fixRead lesssplit the keyRead oncememoise per lifetimeQuery insteadIndexedDB index
The benchmark says what storage costs; the instrumentation says where your code pays it.

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.

Storage performance anti-patternsFour common patterns with their symptom, the measurement that reveals them, and the structural fix.PatternSymptomFixOne huge keyEvery read is slowSplit by access patternget() in a loopRender time scales with itemsRead once, pass data inRe-read per eventEvery message pays the readMemoise + onChangedset() per keystrokeQuota errors, jankCoalesce writes
None of the fixes involve a faster storage API — they all involve reading or writing less.

Cross-browser variation

  • Chrome / Edge: storage.local is backed by a per-extension LevelDB; storage.session is 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.local is 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

  1. Run the benchmark in the worker and record p50 and p95 for your real value sizes.
  2. Enable the slow-read wrapper, use the extension normally for a few minutes, and review any warnings with their stacks.
  3. 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.

  1. 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.

Other Testing, Debugging & Performance Optimization Resources