Migration Guides

From a custom IndexedDB implementation

If your app already has a hand-rolled IndexedDB layer — a getDb/getCached/setCached trio, maybe some manual TTL logic — this page covers what that code is usually missing by the time it's a year old: Safari eviction recovery, LRU eviction, cross-tab write coordination, and per-user scoping, and how each maps onto a Flux registration.

Prerequisites
  • An existing hand-rolled IndexedDB caching layer — object stores, manual TTL envelopes, or similar
  • A state manager Flux can write into — Zustand, Redux, or Jotai — see state-managers/zustand
  • Familiarity with api/register on the client side
1

The pattern, not the specific code

Something close to this exists in a lot of production codebases — usually written once during a "make this feel instant" push, then patched for years afterward.
typescript
1// The pattern this page is about: a hand-rolled IndexedDB layer,
2// usually written once during a "make this app feel instant" push,
3// then carried forward and patched for years. Something close to
4// this exists in a LOT of production codebases:
5
6let dbPromise = null
7function getDb() {
8 if (!dbPromise) {
9 dbPromise = new Promise((resolve, reject) => {
10 const req = indexedDB.open('app-cache', 1)
11 req.onupgradeneeded = () => {
12 req.result.createObjectStore('services')
13 req.result.createObjectStore('team')
14 }
15 req.onsuccess = () => resolve(req.result)
16 req.onerror = () => reject(req.error)
17 })
18 }
19 return dbPromise
20}
21
22async function getCached(store, key) {
23 const db = await getDb()
24 return new Promise((resolve, reject) => {
25 const tx = db.transaction(store, 'readonly')
26 const req = tx.objectStore(store).get(key)
27 req.onsuccess = () => {
28 const entry = req.result
29 if (!entry) return resolve(null)
30 const isStale = Date.now() - entry.cachedAt > 5 * 60 * 1000
31 resolve({ data: entry.data, stale: isStale })
32 }
33 req.onerror = () => reject(req.error)
34 })
35}
36
37async function setCached(store, key, data) {
38 const db = await getDb()
39 return new Promise((resolve, reject) => {
40 const tx = db.transaction(store, 'readwrite')
41 tx.objectStore(store).put({ data, cachedAt: Date.now() }, key)
42 tx.oncomplete = () => resolve()
43 tx.onerror = () => reject(tx.error)
44 })
45}
46
47// This works. It also has no eviction, no Safari recovery, no
48// cross-tab coordination, no scoping, and every new cached resource
49// means writing this same ceremony again with a different store name.
2

What this tends to cost over time

Roughly the order teams discover each of these, based on how a hand-rolled IDB layer usually ages in production.
typescript
1// What tends to happen to a hand-rolled IDB layer over 12-18 months
2// of real use, roughly in the order teams discover each one:
3
4// 1. Someone opens the app on iOS Safari after leaving it backgrounded
5// for a while and every cached image is a broken icon. Safari
6// evicts the connection (onclose) more aggressively than desktop
7// Chrome, and nothing in the getDb() promise above ever detects
8// or recovers from that — it just silently fails from then on.
9
10// 2. Someone opens two tabs, edits the same record in both, and one
11// tab's write silently clobbers the other's, because nothing
12// coordinates writes ACROSS tabs — each tab has its own totally
13// independent connection into the same physical database.
14
15// 3. The cache grows unbounded. A detail-page cache for products or
16// articles has no natural ceiling, and "add an eviction policy"
17// becomes its own multi-day project — sorting by access time,
18// tracking byte sizes, writing the actual deletion pass, getting
19// it right on the FIRST attempt because a bug here means either
20// an unbounded IndexedDB quota or evicting entries that are still
21// in active use.
22
23// 4. Someone builds a login flow and realizes there's no clean way
24// to answer "clear ONLY this user's cached data on logout,"
25// because keys were never namespaced with that boundary in mind
26// from day one — retrofitting it means touching every read and
27// write call site in the codebase.
28
29// Every one of these is a solvable problem in isolation. Together
30// they're most of a small internal library that a team ends up
31// owning indefinitely, alongside the actual product.
3

get/set boilerplate → registration

The connection-promise ceremony and manual staleness math both disappear — registration is a declaration, not a function you call and await on every read.
typescript
1// BEFORE — the getDb/getCached/setCached ceremony from Step 1,
2// repeated per store, with staleness computed by hand against a
3// hardcoded threshold:
4
5const cached = await getCached('services', 'list')
6if (cached && !cached.stale) {
7 render(cached.data)
8} else {
9 const fresh = await fetch('/api/services').then(r => r.json())
10 await setCached('services', 'list', fresh)
11 render(fresh)
12}
13
14// ────────────────────────────────────────────────────────────────
15
16// AFTER — no connection promise to manage, no manual staleness math,
17// no per-store object-store boilerplate. Registration replaces the
18// getDb/getCached/setCached trio entirely:
19
20flux.register({
21 store: servicesStore,
22 channel: 'services',
23 idbKey: 'services:list',
24 ttl: 'medium', // Section 4.1 — short/medium/long map to configured ms
25})
26
27// reads happen automatically on hydrate (page load) and bootstrap
28// (network refresh); writes happen automatically through
29// dispatchToStore (Section 4.11) whenever new data arrives from
30// either path. No direct idbGet/idbSet call needed in application
31// code for the common case.
4

Safari eviction recovery → built in

This one is usually discovered in production, not development, and the fix is easy to get subtly wrong by hand.
typescript
1// BEFORE — the Safari onclose eviction problem usually gets
2// discovered in production, not in development, and the fix is
3// easy to get subtly wrong: detecting the close event, knowing WHEN
4// to retry the connection, and making sure in-flight blob URLs get
5// revoked and regenerated rather than pointing at dead references.
6
7let db
8function openDb() {
9 const req = indexedDB.open('app-cache', 1)
10 req.onsuccess = () => {
11 db = req.result
12 db.onclose = () => {
13 // now what? retry immediately? on next read? on visibility
14 // change? every one of these has tradeoffs, and it's easy to
15 // ship a version that just... doesn't reconnect
16 db = null
17 }
18 }
19 return req
20}
21
22// separately, a hand-rolled image cache using blob URLs directly:
23const url = URL.createObjectURL(blob)
24img.src = url
25// ...tab goes to background, connection drops, comes back into
26// view — this blob URL may now be stale or revoked with no signal,
27// and nothing above knows to regenerate it
28
29// ────────────────────────────────────────────────────────────────
30
31// AFTER — handled inside the IDB engine, not application code.
32// Images are stored as raw ArrayBuffer (never a blob URL directly),
33// and a visibilitychange listener revokes and regenerates every
34// active blob URL from its underlying ArrayBuffer before React's
35// next repaint (Section 4.2):
36
37import { CachedImg } from '@tsworldtech/flux-react'
38
39<CachedImg src={product.imageKey} alt={product.name} />
40// components using CachedImg get fresh, valid blob URLs on tab
41// restore automatically — no onclose handler to write yourself
5

Manual eviction → byte-budget LRU

"Add eviction" tends to become its own multi-day project. Here it's four lines of configuration.
typescript
1// BEFORE — "add eviction" as its own project: tracking last-accessed
2// time per entry, tracking size per entry, writing a sort-and-delete
3// pass, and deciding when that pass runs without blocking a write.
4
5const ACCESS_TIMES = new Map() // in-memory only — lost on reload,
6 // so eviction priority resets every
7 // single page load, defeating the
8 // whole point of LRU
9
10async function setCachedWithEviction(store, key, data) {
11 await setCached(store, key, data)
12 ACCESS_TIMES.set(key, Date.now())
13
14 // now write (and maintain, and debug) a pass that estimates size,
15 // sums it against some budget, sorts by ACCESS_TIMES, and deletes
16 // the oldest entries — none of which persists across a reload
17}
18
19// ────────────────────────────────────────────────────────────────
20
21// AFTER — byte-budget LRU with persisted access times, oversized-
22// write rejection, and a hard ceiling against runaway mobile storage
23// usage, entirely configuration, not code you write or maintain:
24
25flux.register({
26 store: productDetailStore,
27 channel: 'product_detail',
28 idbKey: 'product_detail',
29 ttl: 'long',
30 ingestionType: 'LRU',
31 cacheStrategy: {
32 type: 'LRU',
33 maxBytes: 5 * 1024 * 1024, // clipped to a 50MB hard ceiling
34 // regardless of what's configured
35 // (Section 4.2) — protects mobile
36 // Safari from aggressive eviction
37 trackAccessTime: true,
38 },
39})
40// access times persist in IDB (lru_meta records), survive reload,
41// and eviction runs fire-and-forget on every write without blocking
42// it — a failed eviction pass is non-fatal, never a stuck write
6

Cross-tab write races → mirrored writes

The bug that's hardest to reproduce on demand and easiest to dismiss as a fluke the first few times it's reported.
typescript
1// BEFORE — two tabs, two independent IDB connections, no
2// coordination. This is the bug that's hardest to reproduce on
3// demand and easiest to dismiss as "probably a fluke" the first few
4// times a user reports it.
5
6// tab A
7await setCached('services', 'list', updatedServices)
8
9// tab B, milliseconds later, unaware tab A just wrote
10const cached = await getCached('services', 'list')
11// tab B might read the OLD value if its read raced tab A's write,
12// or tab B might independently re-fetch and overwrite tab A's write
13// right back to stale data — the failure mode depends on timing,
14// which is exactly why it's hard to pin down
15
16// ────────────────────────────────────────────────────────────────
17
18// AFTER — cross-tab writes are mirrored explicitly rather than left
19// to chance. One tab performs a write, broadcasts it, every other
20// tab updates its in-memory store from the broadcast WITHOUT a
21// second IDB write of its own (Section 4.4) — no dual-write race,
22// no re-fetch race, structurally:
23
24flux.register({
25 store: servicesStore,
26 channel: 'services',
27 idbKey: 'services:list',
28 ttl: 'medium',
29})
30// nothing extra to configure — BroadcastChannel mirroring and the
31// shouldPersistToIdb flag that prevents follower-tab double-writes
32// are both automatic once a store is registered
7

Retrofitted per-user isolation → scope

Retrofitting user-boundary isolation onto keys that were never namespaced for it means touching every call site — and a missed one is a data leak, not just a bug.
typescript
1// BEFORE — retrofitting per-user isolation onto keys that were
2// never namespaced with that boundary in mind means touching every
3// read/write call site, and a missed one is a data leak between
4// accounts on a shared machine, not just a bug:
5
6async function getCachedForUser(store, key, userId) {
7 // hope every call site remembers to pass userId, and remembers
8 // to check it hasn't drifted from the CURRENTLY logged in user
9 return getCached(store, `${userId}:${key}`)
10}
11
12function logout() {
13 // now write a sweep that finds every key prefixed with the old
14 // userId across however many object stores exist, without an
15 // index that makes that lookup efficient, because nothing was
16 // designed for this query pattern originally
17}
18
19// ────────────────────────────────────────────────────────────────
20
21// AFTER — scope is a field on the registration, not a convention
22// every call site has to remember. Namespacing and the logout sweep
23// are both handled structurally (Section 4.17):
24
25flux.register({
26 store: dashboardStore,
27 channel: 'dashboard',
28 idbKey: 'dashboard',
29 ttl: 'short',
30 scope: 'user',
31})
32
33// on logout
34await flux.clearUserSession()
35// sweeps every 'user:{id}:' namespaced key for the logged-out user
36// in one atomic pass, leaves 'global:' keys untouched, and is
37// self-healing on next boot even if this call is skipped entirely —
38// see core-concepts/multi-tenant-scoping
Self-healing even if clearUserSession() is skipped

A boot-time sweep catches sessions that never call this at all — a crashed tab, a closed laptop lid — see core-concepts/multi-tenant-scoping.

8

What doesn't move

Not every use of IndexedDB is a "cache with TTL" problem — keep these as-is rather than forcing them through a registration for consistency's sake.
typescript
1// What a hand-rolled IDB layer might be doing that Flux's engine
2// deliberately doesn't try to cover — worth keeping as-is rather
3// than forcing into a registration:
4
5// - Large binary blobs above roughly 5MB (video files, large
6// uploads held for an offline-tab-close scenario) — this is OPFS
7// territory, not IDB-cache territory, and is a deferred feature
8// (Section 15), not something to force through cacheStrategy today.
9
10// - Non-cache uses of IndexedDB — an actual local-first data model
11// where IDB is the source of truth rather than a cache in front of
12// a server (a local notes app with no backend, for instance).
13// Flux's IDB engine assumes a server is the source of truth and
14// the browser is a cache/queue in front of it; a genuinely
15// offline-only data model is a different problem.
16
17// - Custom object stores used for something other than UI-facing
18// caching — feature flags evaluated once at boot and never
19// refreshed, a locally-computed analytics buffer flushed
20// periodically. These aren't "cache with TTL" problems, so there's
21// no reason to route them through register() just for consistency.
9

Migrating without a big-bang cutover

Flux's engine uses its own namespaced database, so the old and new caching layers can run side by side with zero collision during migration.
typescript
1// Migrating store by store, not database by database: Flux's engine
2// uses its own IDB database (namespaced via storagePrefix, Section
3// 4.1), separate from whatever database name a hand-rolled layer
4// was using. This means the two can run side by side during
5// migration with zero collision — there's no shared object store to
6// carefully merge or rename.
7
8const flux = createFlux({
9 adapter: createSupabaseAdapter(supabaseClient),
10 storagePrefix: 'flux_', // isolated from the old 'app-cache' database
11})
12
13// Migrate one register() call at a time, verify it in the IDB
14// inspector, then delete the corresponding hand-rolled
15// getCached/setCached call sites for that resource. The old database
16// can be dropped entirely (indexedDB.deleteDatabase('app-cache'))
17// once nothing reads from it anymore — no rush to do this on day one.

For the full IDB engine internals referenced throughout this page, see core-concepts/idb-ttl. For the offline queue this page doesn't cover, see core-concepts/offline-queue. For the multi-tenant isolation model in Step 7, see core-concepts/multi-tenant-scoping.