Throttling Cache Writes to Storage
Persisting a query cache means serializing it, and serializing it means walking every entry and stringifying every response. Do that on every cache event and a screen that polls, prefetches and paginates will run a full serialization several times a second — on the main thread, between the user’s finger and the pixels. The symptom is a scroll that stutters on exactly the screens with the most cached data. This page covers keeping the writes bounded, under Offline Cache Persistence. It assumes the persister setup from Persisting React Query Cache to IndexedDB, and the versioning that decides what a restored snapshot is worth is in Cache Versioning & Migration.
Diagnostic Checklist
Prerequisites
- A working persister, writing somewhere.
- The Performance panel, or
PerformanceObserverfor long tasks. - A rough idea of your serialized cache size — the inspection helper in Cache Observability & Debugging gives it in one call.
Step 1 — Coalesce Events Into One Deferred Write
The default persister throttles at one second. Widening that, and resetting the timer on each event, collapses a burst into a single write.
import type { Persister, PersistedClient } from '@tanstack/react-query-persist-client';
export function throttledPersister(inner: Persister, windowMs = 4_000, maxDelayMs = 15_000): Persister {
let timer: number | undefined;
let firstQueuedAt: number | null = null;
let queued: PersistedClient | null = null;
const flush = () => {
timer = undefined;
firstQueuedAt = null;
const client = queued;
queued = null;
if (client) void inner.persistClient(client);
};
return {
...inner,
persistClient(client) {
queued = client; // keep only the newest snapshot
firstQueuedAt ??= Date.now();
window.clearTimeout(timer);
// Reset the window on each event, but never starve: a continuously busy
// screen still flushes after maxDelayMs.
const remaining = Math.max(0, maxDelayMs - (Date.now() - firstQueuedAt));
timer = window.setTimeout(flush, Math.min(windowMs, remaining));
},
};
}
Cache Behavior Analysis: Keeping only the newest PersistedClient is what makes coalescing free rather than a queue — each call from persistQueryClient hands over a complete snapshot, so nine snapshots collapse into the last one with no loss. The maxDelayMs ceiling exists because a pure debounce starves under continuous activity: a screen polling every three seconds with a four-second window would never write at all. Note that persistQueryClient calls dehydrate before handing you the snapshot, so the serialization cost of building it is already paid by the time this function runs — Step 2 is what moves that cost off the interaction path.
Step 2 — Serialize Off the Interaction Path
Building the snapshot is the expensive part. Scheduling it during idle time keeps it out of the frames the user can feel.
export function idlePersistClient(client: QueryClient, persister: Persister, key: string) {
let scheduled = false;
const schedule = () => {
if (scheduled) return;
scheduled = true;
const run = (deadline?: IdleDeadline) => {
scheduled = false;
// If there is not enough idle time, defer again rather than overrun.
if (deadline && deadline.timeRemaining() < 8 && !deadline.didTimeout) {
schedule();
return;
}
void persister.persistClient({
buster: BUILD_BUSTER,
timestamp: Date.now(),
clientState: dehydrate(client, { shouldDehydrateQuery: shouldPersist }),
});
};
if ('requestIdleCallback' in window) {
// The timeout guarantees it eventually runs even on a permanently busy tab.
window.requestIdleCallback(run, { timeout: 10_000 });
} else {
window.setTimeout(run, 500);
}
};
const unsubscribe = client.getQueryCache().subscribe((event) => {
if (event.type === 'updated' && event.action.type === 'success') schedule();
});
// A hidden tab may never come back. Write synchronously on the way out.
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
void persister.persistClient({
buster: BUILD_BUSTER, timestamp: Date.now(),
clientState: dehydrate(client, { shouldDehydrateQuery: shouldPersist }),
});
}
});
return unsubscribe;
}
Cache Behavior Analysis: Calling dehydrate inside the idle callback rather than at subscribe time means the snapshot reflects the cache as it is when the write actually happens, so a burst that ends mid-schedule still persists its final state. The timeout on requestIdleCallback is not optional: a tab that never goes idle — an animation loop, a busy dashboard — would otherwise never persist at all. Flushing on visibilitychange covers the case idle scheduling cannot, which is a tab being closed or backgrounded before the callback fires; that write is deliberately synchronous in intent, which is another argument for IndexedDB, whose put at least does not block the main thread while it completes.
Step 3 — Persist Only What Changed
A whole-cache snapshot rewrites entries that did not move. Tracking dirty hashes turns a full write into a delta.
export function createDeltaPersister(db: IDBDatabase) {
const dirty = new Set<string>();
function markDirty(hash: string) {
dirty.add(hash);
}
async function flush(client: QueryClient) {
if (dirty.size === 0) return;
const hashes = [...dirty];
dirty.clear();
const tx = db.transaction('queries', 'readwrite');
const store = tx.objectStore('queries');
for (const hash of hashes) {
const query = client.getQueryCache().get(hash);
// An entry that has since been garbage collected must be deleted, not skipped.
if (!query || query.state.data === undefined) store.delete(hash);
else store.put({ hash, key: query.queryKey, data: query.state.data, at: query.state.dataUpdatedAt });
}
await new Promise<void>((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
return { markDirty, flush };
}
Cache Behavior Analysis: Writing per entry rather than as one blob means the serialization cost is proportional to what changed rather than to the cache size, which is the difference between a constant cost and one that grows all session. Deleting entries whose query has disappeared is the part that is easy to miss: without it, the persisted store accumulates records for keys the cache garbage-collected long ago, and the restored cache is larger than the live one ever was. The trade is that restore must now read many records and reassemble a DehydratedState, which is slower than parsing one blob — worth it above a few hundred kilobytes, not below.
Step 4 — Handle Quota Without Losing the Cache
A QuotaExceededError that is swallowed means persistence silently stops. Catch it, shrink, and report.
export function quotaAwarePersister(inner: Persister, onQuota: (bytes: number) => void): Persister {
return {
...inner,
async persistClient(client) {
try {
await inner.persistClient(client);
} catch (error) {
const isQuota =
error instanceof DOMException &&
(error.name === 'QuotaExceededError' || error.name === 'NS_ERROR_DOM_QUOTA_REACHED');
if (!isQuota) throw error;
onQuota(JSON.stringify(client.clientState).length);
// Retry with the largest entries dropped rather than giving up entirely.
const trimmed = {
...client,
clientState: {
...client.clientState,
queries: [...client.clientState.queries]
.sort((a, b) => JSON.stringify(a.state.data).length - JSON.stringify(b.state.data).length)
.slice(0, Math.floor(client.clientState.queries.length / 2)),
},
};
try {
await inner.persistClient(trimmed);
} catch {
// Still too big: remove the stored client so a stale snapshot does
// not outlive the data it was meant to accompany.
await inner.removeClient();
}
}
},
};
}
Cache Behavior Analysis: Retrying with the smallest half rather than abandoning the write keeps the most reusable entries — small, frequently read records like the session and reference data — which is where most of the hit-rate benefit lives anyway. Removing the stored client on a second failure is deliberate: a snapshot that is now inconsistent with what the application expects is worse than no snapshot, because it will be restored and trusted. Reporting the attempted size is what makes this actionable; quota limits vary enormously by browser and storage mode, so the only way to know you are near one is to measure at the moment you exceed it.
Edge Cases & Gotchas
localStorage is synchronous. Every write blocks the main thread for its full duration, so a 2 MB cache is a visible stall regardless of how well you throttle. IndexedDB is the answer above a few hundred kilobytes.
Safari private mode. Historically threw on setItem once quota was reached, and the quota was small. Wrap the first write in a try and disable persistence rather than throwing on every subsequent one.
Several tabs writing. Two tabs persisting to one key means the last writer wins, silently discarding the other’s snapshot. Let the leader own persistence — see Electing a Leader Tab for Refetching.
Common Pitfalls & Resolutions
| Observable Issue | Root Cause | Diagnostic Resolution |
|---|---|---|
| Scrolling stutters on data-heavy screens | The cache is serialized on every event, on the main thread | Coalesce into a deferred, idle-scheduled write |
| A busy tab never persists at all | A pure debounce is starved by continuous activity | Add a maximum delay ceiling and an idle-callback timeout |
| The persisted store keeps growing | Delta writes never delete collected entries | Delete records for hashes no longer in the cache |
| Persistence stopped on some devices | A QuotaExceededError is being swallowed |
Catch it explicitly, trim, and report the attempted size |
| A snapshot from another tab disappeared | Two tabs write the same storage key | Give persistence to the elected leader tab |
Frequently Asked Questions
How often does the default persister write?
persistQueryClient subscribes to the query cache and writes whenever it changes, throttled to once per second by default. That sounds modest until you notice what a write involves: dehydrate walks every query in the cache and the persister serializes the result, so the cost is proportional to the whole cache rather than to the change. On a screen with a few megabytes cached, once a second is a serialization per second on the main thread — enough to be visible in a scroll, and entirely avoidable since the data being written barely differs from the previous second’s.
Is localStorage or IndexedDB better for a persisted cache?
IndexedDB, for anything beyond a very small cache, and the deciding factor is not quota but synchrony. localStorage.setItem blocks the main thread for the entire duration of the write, so a two-megabyte snapshot is a two-megabyte stall no matter how cleverly you schedule it. IndexedDB writes asynchronously and offers a far larger quota, at the cost of a slightly more involved persister. The one thing localStorage is genuinely good for is a tiny, high-value payload — a session pointer or a schema version — where the synchronous write is a feature.
What happens when the quota is exceeded?
The write throws a QuotaExceededError, and what happens next depends entirely on your error handling. The common outcome is that a catch somewhere swallows it and persistence simply stops — no error, no telemetry, and nobody notices until a returning user reports that the application is slow to load. Catching it explicitly gives you three useful options: report the attempted size so you learn the real limit on real devices, retry with the largest entries dropped, and as a last resort remove the stored snapshot so an inconsistent one is not restored later.
Related
- Offline Cache Persistence — the parent guide on writing cache state to storage.
- Persisting React Query Cache to IndexedDB — the persister these throttling strategies wrap.
- Cache Versioning & Migration — deciding what a restored snapshot is worth before you spend effort writing it.
- Electing a Leader Tab for Refetching — giving persistence a single writer across tabs.