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.
Benchmark chrome.storage.local against chrome.storage.sync in MV3: latency, throughput, quota limits and tiered-write patterns to keep your extension responsive.
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.
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.
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 area | Avg set | Avg get |
|---|---|---|
chrome.storage.local | 1 – 5 ms | 1 – 3 ms |
chrome.storage.sync | 20 – 200 ms | 5 – 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.
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 category | Recommended area | Reason |
|---|---|---|
| DNR rule cache | local | High write frequency, may exceed 8 KB |
| Session tokens / auth state | local | Never replicate to Google servers |
| UI configuration, feature flags | sync via StorageManager | Small, benefits from cross-device sync |
| Telemetry / analytics buffers | local | High volume, exceeds sync quota |
| User preferences (theme, shortcuts) | sync via StorageManager | Small, infrequent, user expects cross-device |
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 area | Per-item limit | Total limit | Write rate limit | Network I/O |
|---|---|---|---|---|
chrome.storage.sync | 8 KB | 100 KB | ~1 800 ops/hr | Yes — Google account |
chrome.storage.local | No per-item cap | 10 MB (default) | None | No |
chrome.storage.session | No per-item cap | 10 MB | None | No — 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.
browser.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.onChanged event for sync keys also fires with a larger delay than on Chrome.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.
After deploying StorageManager, confirm the tiered behavior is working:
chrome://extensions, enable Developer mode, and click Service worker next to your extension to open its DevTools.benchmarkStorage('local', 10) and benchmarkStorage('sync', 10) manually. Confirm local averages are under 10 ms and sync averages reflect the expected network-dependent range.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.[StorageManager] sync flush failed warning, and the Local storage area should retain the most recent values.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.*
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.
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.
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.
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.
QUOTA_BYTES and QUOTA_BYTES_PER_ITEM rejectionsChrome vs Firefox vs Safari Manifest V3 API compatibility reference — namespace differences, Promise vs callback, storage quotas, declarativeNetRequest, and the webextension-polyfill.
Use chrome.scripting in Manifest V3 to dynamically inject JavaScript and CSS — executeScript, registerContentScripts, world isolation, and activeTab patterns.
Persist and synchronise extension state across devices with chrome.storage.sync — quotas, async patterns, change events, encryption and cross-browser adapters for Manifest V3.
Master chrome.declarativeNetRequest static, dynamic, and session rulesets in MV3 — rule schema, modifyHeaders, redirect, block actions, quota limits, and getMatchedRules debugging.