Local vs Sync Storage Performance Comparison

Benchmark chrome.storage.local against chrome.storage.sync in MV3: latency, throughput, quota limits and tiered-write patterns to keep your extension responsive.

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

Extensions that store everything in chrome.storage.sync often develop a specific failure signature: writes that stall for hundreds of milliseconds, quota errors appearing without warning, and state that silently fails to persist when the service worker is evicted mid-operation. The fix is not to avoid sync altogether but to understand its I/O model and route each category of data to the right storage area. This page benchmarks both areas, explains the architectural differences, and gives you a tiered write pattern that prevents these symptoms. For a full overview of the storage API surface, see chrome.storage.sync deep dive.

Why the I/O paths differ

chrome.storage.local resolves against a browser-managed SQLite or LevelDB file on the local filesystem. The operation is synchronous at the OS level — a write goes from the extension process to the storage backend in a single IPC hop with no network involvement. Round trips consistently land between 1 ms and 5 ms under normal system load.

chrome.storage.sync inserts a second stage: Google’s extension sync infrastructure serializes the payload, queues it against a rate limiter, and replicates it to the user’s Google account before the promise resolves. Under a clean network this costs 20 ms to 200 ms. Under a slow network, congested sync quota, or cold service worker start, writes can exceed one full second.

  Extension process
       │
       ▼
  chrome.storage API
       │
       ├──[local]──► Browser IPC ──► SQLite/LevelDB  ──► resolve (~1-5 ms)
       │
       └──[sync]───► Browser IPC ──► Sync daemon ──► Rate limiter
                                                          │
                                          Google account replication
                                                          │
                                                     resolve (~20-200 ms+)

MV3 tightens this constraint further. Because service workers are evicted after roughly 30 seconds of inactivity, a slow sync write that starts near the eviction boundary may never fire its resolution callback — the worker terminates, the promise is abandoned, and the write may or may not have committed on the remote side. Local writes, being orders of magnitude faster, clear the event loop well before eviction pressure builds.

Step 1 — Benchmark your actual extension payload

Do not estimate latency. Measure it against the same payload size and key structure your extension uses in production. The function below runs sequential write/read pairs against both storage areas and logs averaged timings.

 1// background.ts (service worker)
 2async function benchmarkStorage(
 3  storageArea: 'local' | 'sync',
 4  iterations: number = 50
 5): Promise<void> {
 6  const payload = { data: 'x'.repeat(1_000), timestamp: Date.now() };
 7  const results: { op: string; ms: number }[] = [];
 8
 9  for (let i = 0; i < iterations; i++) {
10    const setStart = performance.now();
11    await chrome.storage[storageArea].set({ [`test_key_${i}`]: payload });
12    results.push({ op: 'set', ms: performance.now() - setStart });
13
14    const getStart = performance.now();
15    await chrome.storage[storageArea].get(`test_key_${i}`);
16    results.push({ op: 'get', ms: performance.now() - getStart });
17  }
18
19  const avg = (op: string) =>
20    results.filter(r => r.op === op).reduce((a, b) => a + b.ms, 0) / iterations;
21
22  console.log(
23    `[${storageArea}] Avg set: ${avg('set').toFixed(2)} ms | Avg get: ${avg('get').toFixed(2)} ms`
24  );
25}
26
27// Call from a one-time install handler so it doesn't run on every startup
28chrome.runtime.onInstalled.addListener(async () => {
29  await benchmarkStorage('local');
30  await benchmarkStorage('sync');
31});

Execution context: Run this inside the service worker (background.ts) rather than from a popup or content script. Cross-context calls add an extra IPC round trip that inflates sync timings and misrepresents the real overhead. Firefox reports near-identical local and sync times when Firefox Sync is disabled — see the cross-browser section below for why.*

The payload uses a 1 KB string — well under the 8 KB per-item sync limit — to isolate I/O latency rather than serialization cost. Once you have baseline numbers, re-run with your real key schema to confirm the distribution matches expectations.

What to expect:

Storage areaAvg setAvg get
chrome.storage.local1 – 5 ms1 – 3 ms
chrome.storage.sync20 – 200 ms5 – 30 ms

Get times are faster for sync because reads are served from a local cache; only writes must round-trip through the sync daemon.

Step 2 — Route data with a tiered storage manager

Once you have benchmark numbers, apply a consistent routing rule: write high-frequency or large data to local immediately, and queue preference-sized data for sync behind a debounce. This prevents sync rate-limit errors and keeps reads fast for all callers.

 1// storage-manager.ts (imported by service worker)
 2class StorageManager {
 3  private queue = new Map<string, unknown>();
 4  private debounceTimer: ReturnType<typeof setTimeout> | null = null;
 5
 6  async set(key: string, value: unknown): Promise<void> {
 7    // Fast path: local write resolves in ~1-5 ms
 8    await chrome.storage.local.set({ [key]: value });
 9
10    // Slow path: queue for sync replication
11    this.queue.set(key, value);
12    if (this.debounceTimer) clearTimeout(this.debounceTimer);
13    this.debounceTimer = setTimeout(() => void this.flushSyncQueue(), 1_200);
14  }
15
16  async get(key: string): Promise<unknown> {
17    // Always read from local — it holds the freshest copy after a set()
18    const result = await chrome.storage.local.get(key);
19    return result[key];
20  }
21
22  private async flushSyncQueue(): Promise<void> {
23    const batch = Object.fromEntries(this.queue);
24    this.queue.clear();
25    try {
26      await chrome.storage.sync.set(batch);
27    } catch (err) {
28      // QUOTA_BYTES_PER_ITEM or rate-limit error: stay in local
29      console.warn('[StorageManager] sync flush failed, local copy retained:', err);
30    }
31  }
32}
33
34export const storage = new StorageManager();

Execution context: Instantiate StorageManager once at module scope in the service worker. The 1.2 s debounce coalesces rapid writes from the same event loop turn and stays well inside Chrome’s sync write rate limit (approximately one burst per second, with a sustained budget of roughly 1 800 operations per hour). Do not use setTimeout as a service worker keepalive — that pattern causes forced terminations in MV3. The debounce here is purely a write-batching strategy, not a state workaround.*

Data routing guide:

Data categoryRecommended areaReason
DNR rule cachelocalHigh write frequency, may exceed 8 KB
Session tokens / auth statelocalNever replicate to Google servers
UI configuration, feature flagssync via StorageManagerSmall, benefits from cross-device sync
Telemetry / analytics bufferslocalHigh volume, exceeds sync quota
User preferences (theme, shortcuts)sync via StorageManagerSmall, infrequent, user expects cross-device

Step 3 — Quota and limit reference

Keep this table in view when designing schemas. Exceeding per-item limits throws a QUOTA_BYTES_PER_ITEM error synchronously before any network call is made. Total quota errors surface asynchronously and are easy to miss without an explicit .catch(). See handling storage quota exceeded errors for a complete error-handling pattern.

Storage areaPer-item limitTotal limitWrite rate limitNetwork I/O
chrome.storage.sync8 KB100 KB~1 800 ops/hrYes — Google account
chrome.storage.localNo per-item cap10 MB (default)NoneNo
chrome.storage.sessionNo per-item cap10 MBNoneNo — cleared on browser close

chrome.storage.session is useful for data that should not survive a browser restart (e.g., OAuth access tokens, ephemeral UI state). It is not replicated and has no per-item limit, making it a faster alternative to local for in-session caches. It does not fit the sync routing decision but belongs in the same architectural conversation.

Cross-browser variation

  • Chrome — The reference implementation. Sync resolves against Google’s extension sync infrastructure. Timings in the benchmark section reflect Chrome under a logged-in profile with a good network connection.
  • Firefoxbrowser.storage.sync uses a local SQLite database until the user explicitly enables Firefox Sync in browser settings. This means sync and local benchmarks will show nearly identical latency during development. Do not assume sync is fast based on Firefox development numbers; test on Chrome before shipping.
  • Safari — Sync uses iCloud and is throttled aggressively to preserve battery on macOS and iOS. Write operations may be deferred by several seconds under power-saving conditions. The onChanged event for sync keys also fires with a larger delay than on Chrome.
  • Edge — Inherits Chromium quota limits exactly but applies stricter background process memory caps. Extensions running high-volume telemetry writes to local can trigger out-of-memory kills earlier than Chrome would. Prefer batching writes on Edge even for local storage.

Always wrap sync calls in a try/catch or .catch() handler. A network-unavailable error on sync should degrade gracefully to the local copy rather than blocking the UI.

Verification

After deploying StorageManager, confirm the tiered behavior is working:

  1. Open chrome://extensions, enable Developer mode, and click Service worker next to your extension to open its DevTools.
  2. In the Console tab, call benchmarkStorage('local', 10) and benchmarkStorage('sync', 10) manually. Confirm local averages are under 10 ms and sync averages reflect the expected network-dependent range.
  3. In the Application tab, expand Extension Storage and select the Sync area. After triggering storage.set() calls in rapid succession, verify that the sync area is not updated on every call — the debounce should coalesce them into one write every ~1.2 s.
  4. Disconnect the machine from the network and repeat the writes. The console should log the [StorageManager] sync flush failed warning, and the Local storage area should retain the most recent values.
  5. Add a quota guard to your benchmark to catch errors during load testing:
1chrome.storage.sync.getBytesInUse(null).then(bytes => {
2  console.log(`Sync bytes in use: ${bytes} / 102400`);
3  if (bytes > 90_000) {
4    console.warn('Approaching sync quota limit — review key schema');
5  }
6});

Execution context: Service worker console only. chrome.storage.sync.getBytesInUse is not available in content scripts without a message-passing bridge.*

FAQ

When should I use local instead of sync for user preferences?

Prefer local if any preference value can exceed 8 KB (e.g., a large blocklist or custom CSS snippet), if the preference contains personally identifiable information that should not leave the device, or if the user has not signed into their browser. sync is appropriate only for small, text-safe values where cross-device propagation is a genuine user benefit.

What happens if the service worker is evicted while a sync write is in flight?

The promise is abandoned — its resolution callback will never run. The sync daemon may or may not commit the write to Google’s servers depending on how far the operation progressed. The safest recovery strategy is to write to local first (as StorageManager does), so the most recent value is always available locally regardless of whether sync completed. On the next service worker wake-up you can check chrome.storage.local against chrome.storage.sync and reconcile any divergence.

How do I detect quota errors during benchmarking?

Wrap each set call in a try/catch and inspect the error message string. Chrome throws with messages like "QUOTA_BYTES_PER_ITEM quota exceeded" or "QUOTA_BYTES quota exceeded". Log the key name and byte size alongside the error so you can identify which keys are over-limit. The StorageManager.flushSyncQueue method above already catches these — extend it to emit structured telemetry if you need to track quota pressure over time.

Does chrome.storage.session belong in this local vs sync decision?

Only partially. session storage behaves like local for latency purposes — no network hop, no per-item cap, similar throughput — but it is scoped to the browser session and cleared when the browser closes. Use it for ephemeral state like access tokens or in-progress form drafts that should not be persisted between sessions. It does not replace local for durable data or sync for cross-device data; it adds a third tier for session-scoped data that should never touch disk or the network.

Other Core APIs & Cross-Browser Data Management Resources