Cache System Overview
July 25, 2026 · View on GitHub
Three-tier cache for regenerable data. In-process memory, cross-window shared state, and localStorage-backed persistence.
Scope
Use Cache for data that:
- Can be regenerated or lost without user impact
- Needs no backup or cross-device sync
- Has lifecycle tied to a component, window, or app session
For user settings use Preference; for business data use DataApi.
Tiers
| Tier | Scope | Survives restart | Authority | Use for |
|---|---|---|---|---|
| Memory | Per-process | No | Local to each process | Computed results, API responses |
| Shared | All renderer windows + Main | No | Main (relays + conflict sink) | Cross-window UI state |
| Persist (Renderer) | All renderer windows | Yes (localStorage) | Each renderer | Recent items, non-critical UI state |
| Persist (Main) | Main process only | Yes (JSON file) | Main | Loseable main-process state |
Persist has two independent stores. Each renderer persists to its own localStorage; Main persists to its own JSON file ({userData}/cache.json) exposed as getPersist / setPersist / hasPersist / deletePersist (plus subscribePersistChange) on the Main CacheService. The two never share data — Main cannot read renderer persist and vice versa. Separately, Main still relays renderer-origin CacheSyncMessage { type: 'persist' } between windows (it forwards them; it does not store the renderer's persist).
Reach for the Main persist tier last. It was the last tier added, for a deliberately narrow need: small, loseable, main-process-authoritative state that genuinely belongs nowhere else. Before choosing it, rule out the better-fitting systems first — a user setting belongs in Preference; cross-window or renderer-owned UI state belongs in Shared / renderer Persist; business data belongs in DataApi. In the vast majority of cases one of those is the right answer, so use Main persist only when the state is owned by the main process, regenerable, and has no home in any other system. See System Selection for the full decision guide.
Key Types
| Type | Example schema | Call site | Tiers |
|---|---|---|---|
| Fixed | 'app.user.avatar': string | get('app.user.avatar') | Memory / Shared / Persist |
| Template | 'scroll.position.${topicId}': number | get('scroll.position.t42') | Memory / Shared |
| Casual | (none — type argument only) | getCasual<T>('my.dynamic.key') | Memory only |
Template keys share one default value across all instances — all web_search.provider.last_used_key.* fall back to ''. Casual keys are blocked at compile time from matching any schema pattern (UseCacheCasualKey in src/shared/data/cache/cacheSchemas.ts:393).
Design Invariants
Non-obvious rules the code enforces; assume them when designing consumers.
- Same-value write never fires subscribers or re-renders. Equality via
isEqual(es-toolkit/compat). It is a full no-op (no broadcast either) only whenexpireAtis also unchanged — a same-value write that moves the TTL still broadcasts to mirrors, see invariant 2. (src/main/data/CacheService.tsisEqualguards beforebroadcastSync/ notifier) — Corollary for the hooks' functional updatersetX(prev => …): it must return a new value. Mutatingprevin place and returning the same reference compares the stored value against itself, so this no-op short-circuit silently swallows the update (the hooks typeprevshallow-readonly to block the common top-level case). - TTL-only refresh does not fire subscribers — but does sync mirrors. Updating
expireAton the same value fires no main value-subscriber and no hook re-render. Main still broadcasts the full entry so every renderer mirror renews itsexpireAtin step; the receiving renderer renews in place, keeping the old value reference (an equal-value heartbeat never causes re-renders). - Subscribers fire only on explicit writes; eviction still reaches mirrors. Lazy TTL cleanup, the 10-min GC sweep, and
onStopnever fire main value-subscribers. Every Main-origin runtime eviction (lazy TTL cleanup, GC sweep,deleteSharedhitting an expired entry) still broadcasts one deletion tombstone so renderer mirrors — which have no GC — physically converge; renderer hooks DO re-render on that tombstone (the value visibly disappears).onStopclears without broadcasting. - Shared expiry is eventually consistent — never expiry-instant. External-store snapshots (
getSharedSnapshot, the reader behind all shared cache hooks) are pure physical reads: no TTL evaluation, no store mutation. An expired entry may briefly keep serving its old value until Main's tombstone lands (next Main read of the key, or the GC sweep — upper bound TTL + 10 min) or this window's own imperativegetSharedevicts it locally. Do not design UI that needs a value to vanish at the TTL instant;useCache/useSharedCachestill warn when a hooked key carries TTL. - Writable hooks pin cache entries.
registerHook/unregisterHookrefcount keys;delete/deleteSharedreturnfalsewhile any hook is active. The read-onlyuseSharedCacheValuedoes NOT pin (and never writes a default) — an owner's deletion always passes through observers. - Persist presence means "overridden", not "stored". Both persist tiers (Main JSON + renderer localStorage) have no absent state —
getPersistalways returns the stored override or the schema default (never undefined).hasPersistreports whether the effective value differs from the default (i.e. has been overridden), anddeletePersistresets a key to its default rather than removing it. Keys are fixed by schema. Change subscription differs by process in API shape only: Main exposes a dedicatedsubscribePersistChange(main-local, same model assubscribeChange; never relayed to renderers), while the renderer routes persist changes through its unifiedsubscribe(key, cb). - TTL uses absolute
expireAt(Unix ms). Every process expires the same entry at the same instant, regardless of clock skew in IPC delivery. - Main-wins convergence. All cross-window shared writes are serialized through Main; on window init, Main-priority override applies to conflicts with the renderer's pre-sync copy.
- Re-entrant callbacks are safe. Subscribers may write back into the same key; the
isEqualshort-circuit terminates loops once the value stabilizes. Callback errors are caught and logged without skipping other subscribers. - Template placeholders are runtime-anonymous.
${providerId}and${foo}match identical concrete keys. Dynamic segments match[\w\-]+only — dots, colons, and non-ASCII are rejected (src/shared/data/cache/templateKey.ts:35-46).
Architecture
┌─────────────────────── Renderer Process ──────────────────────┐
│ useCache / useSharedCache / usePersistCache │
│ │ │
│ ▼ │
│ CacheService (Renderer) │
│ - Memory cache (local) │
│ - Shared cache (local copy; init-synced from Main) │
│ - Persist cache (localStorage, authoritative) │
└──────────────────────────┬────────────────────────────────────┘
│ IPC: Cache_Sync / Cache_GetAllShared
┌──────────────────────────▼────────────────────────────────────┐
│ CacheService (Main) │
│ - Internal cache (Main-only) │
│ - Shared cache (authoritative; relays to all windows) │
│ - Persist: own JSON store + relays renderer persist │
│ - subscribeChange / subscribeSharedChange for Main services │
└───────────────────────────────────────────────────────────────┘
Both channels are sender-gated by validateSender (untrusted Cache_Sync messages are dropped, Cache_GetAllShared rejects) — see IpcApi Overview §Security.
Process Responsibilities
| Concern | Main | Renderer |
|---|---|---|
| Internal memory cache | Yes (services' own scratch space) | Yes (window-local) |
| Shared cache authority | Yes | Local copy; writes broadcast via IPC to Main |
| Persist cache storage | Yes (own JSON file, debounced 350ms, flush on stop); also relays renderer persist sync | Yes (localStorage, debounced 350ms, flush on unload) |
| Init sync for new windows | Serves getAllShared() | Calls getAllShared() on startup |
subscribeChange / subscribeSharedChange | Main-only API; template-aware | — |
| Hook refcounting | — | registerHook / unregisterHook |
| GC (10-min sweep of expired) | Yes | — |
API Reference
Renderer
| Method | Tier | Key type |
|---|---|---|
useCache / get / set / has / delete / hasTTL | Memory | Fixed + Template |
getCasual / setCasual / hasCasual / deleteCasual / hasTTLCasual | Memory | Dynamic only (schema keys blocked) |
useSharedCache / getShared / setShared / hasShared / deleteShared / hasSharedTTL | Shared | Fixed + Template |
useSharedCacheValue — read-only observer for main-owned keys: no default write-back, no pin, no setter; undefined on physical miss | Shared | Fixed + Template |
useSharedCacheSelector — multi-key read-only aggregate observer: values tuple → selector → selection-level bail-out | Shared | Fixed + Template |
usePersistCache / getPersist / setPersist (value or (prev) => next) / hasPersist / deletePersist | Persist | Fixed only |
isSharedCacheReady / onSharedCacheReady | Shared | — |
getStats(includeDetails?: boolean) | All | — |
Main
| Method | Tier | Key type |
|---|---|---|
get / set / has / delete | Internal | Free-form string |
getShared / setShared / hasShared / deleteShared | Shared | Fixed + Template |
getPersist / setPersist / hasPersist / deletePersist | Persist (Main) | Fixed only |
subscribeChange<T>(key, cb) | Internal | Exact key |
subscribeSharedChange<K>(key, cb) | Shared | Fixed + Template (fires for every matching concrete instance) |
subscribePersistChange<K>(key, cb) | Persist (Main) | Exact key (main-local) |
See Also
- Cache Usage — React hooks, direct API, patterns
- Cache Schema Guide — Adding fixed and template keys
- Source:
src/main/data/CacheService.ts,src/renderer/data/CacheService.ts,src/renderer/data/hooks/useCache.ts,src/shared/data/cache/