Core Concepts

IDB Engine & TTL

How Flux decides what to keep on disk, for how long, and what actually happens the instant that time runs out. This is Layer 1 of the resilience stack — the reason a returning visitor's app paints instantly instead of waiting on the network.

The core idea: expired data is not deleted data

A TTL in Flux is not an expiry timer that clears the cache when it fires. It's a freshness marker. When a piece of cached data outlives its TTL, Flux still serves it to your component immediately — instantly, from disk. What happens next is not automatic — it depends on whether this channel has a live realtime connection, which is the part most caching systems get wrong. Read on.

1

Every registered channel gets a TTL

ttl is a required field on every register() call, and it only ever takes one of three values: 'short', 'medium', or 'long'. You define what those three actually mean, in milliseconds, once — in your createFlux() config — and every registration in your app just picks the bucket that fits.
lib/flux.ts (excerpt)
1const flux = createFlux({
2 adapter: createSupabaseAdapter(supabase),
3 ttl: {
4 short: 1000 * 60 * 5, // 5 minutes — things that change often
5 medium: 1000 * 60 * 60, // 1 hour — moderate churn
6 long: 1000 * 60 * 60 * 24, // 24 hours — rarely changes
7 },
8})
Three named buckets instead of a raw number on every registration means changing your app's overall freshness policy — tightening short from 5 minutes to 1, say — is a one-line change in one place, not a search-and-replace across every store you've registered.
lib/flux.ts (excerpt)
1flux.register({
2 channel: 'pricing_table',
3 idbKey: 'pricing_table:latest',
4
5 // 'short' | 'medium' | 'long' — map these to real durations once,
6 // in your createFlux() config, and reuse them everywhere.
7 ttl: 'long',
8
9 ingestionType: 'COLLECTION_ALL',
10 diffBeforeUpdate: true,
11 event: 'UPDATE',
12 store: pricingStoreAdapter,
13})
A rough guide for picking a bucket: short for things that change often and where staleness is visible to the user (live metrics, a queue count). medium for moderate churn (a notifications list, a team roster). long for data that rarely changes at all (pricing tables, site configuration, terms pages).
2

TTL expiry does not always mean a refetch

This is the part that's easy to assume works like a typical cache and doesn't. When cached data outlives its TTL, Flux checks one thing before deciding what to do next: is this channel already receiving realtime updates right now?

If realtime is connected for this channel, Flux does not fire a background refetch at all — even though the TTL has technically expired. A live connection means any change to this data would already have arrived as a realtime event and been applied. Refetching over HTTP on top of that would just be asking the server the same question realtime has already been answering continuously. Instead, Flux simply refreshes the data's freshness clock, so the TTL countdown restarts without a single network request being made.

If realtime is not connected — no adapter configured, or the connection is still reconnecting — that's when a real HTTP refetch happens, and only for the specific channels that are actually stale, not a full re-fetch of everything.

Why this matters

Without this check, an app with a healthy, active realtime connection would still fire a redundant HTTP request every time a channel's TTL lapsed — even though the cache was already provably correct. Flux treats "realtime is connected and current" as its own form of freshness, separate from the TTL clock, so your backend never gets asked a question it already answered a second ago.

3

What happens when your app reconnects

Coming back online — after a dropped connection, a closed laptop lid, or a tab that was simply backgrounded — is handled differently from a routine TTL expiry, because there's now a real gap to account for: time passed where nothing was being confirmed either way.

When the realtime connection re-establishes, Flux tells it exactly what each channel last knew, so the very first thing that happens on reconnect is a targeted catch-up — not a blind refetch of everything, and not silence either. If anything actually changed while your app was disconnected, those changes stream in and get applied immediately, and the freshness clock updates as part of that. If nothing changed at all, Flux still refreshes the freshness clock to right now, because realtime just positively confirmed the cached data is current — there's no reason to treat it as stale a moment later.

Only if realtime does not come back — no connection at all, or it's still reconnecting after a short grace period — does Flux fall back to a selective HTTP refetch, and even then, only for the channels that are genuinely still stale once that grace period has passed. A channel that reconnects and catches up successfully during that window never triggers a refetch at all.

The practical result: on a normal reconnect with realtime coming back up quickly, your app never makes a single redundant HTTP request — the catch-up over the live connection is the only thing that happens. A full HTTP refetch is reserved for the case where realtime genuinely isn't available to do that job.
4

What happens on every cold read

Separately from TTL and reconnect behavior, every read from the local cache — first load, a tab reopening, a page revisit — resolves to exactly one of three outcomes:

Fresh. Data is within its TTL. It's dispatched to your store immediately. Nothing else happens.

Stale. Data exists but has outlived its TTL. It's still dispatched to your store immediately — so the UI is never empty — and your registration's onStale callback fires, if you provided one. What happens after that follows the realtime-aware behavior described above, not an automatic refetch.

Missing. Nothing cached yet for this channel. Your store's initial/loading state is left as-is, and the UI waits for the network — same as any app without a cache layer, just for the very first visit only.

lib/flux.ts (excerpt)
1flux.register({
2 channel: 'dashboard_metrics',
3 idbKey: 'dashboard_metrics:latest',
4 ttl: 'short',
5
6 ingestionType: 'COLLECTION_ALL',
7 diffBeforeUpdate: true,
8 event: 'UPDATE',
9 store: dashboardStoreAdapter,
10
11 // Fires the moment Flux serves data past its TTL. Whether that leads
12 // to a network refetch depends entirely on whether realtime is
13 // connected for this channel — see "TTL expiry does not always mean
14 // a refetch" below.
15 onStale: () => {
16 console.log('Serving cached dashboard data past its freshness window')
17 },
18})
One store failing to read — a corrupted entry, an unexpected shape — never blocks any other registered store from loading. And regardless of how many channels you've registered or how slow the device's disk is, there's a hard ceiling on how long your app will wait for cached data before rendering anyway: a couple of seconds, worst case, then the app starts with whatever did make it back in time.
5

Two data shapes, two caching strategies

Not all data belongs in the cache the same way. Flux distinguishes between bounded data — a homepage object, a pricing table, a team roster — and unbounded data — a growing library of blog posts, an ever-expanding product catalog. Bounded data is cached and read back as a whole. Unbounded data uses a different strategy entirely: an LRU (least-recently-used) partition capped by a byte budget rather than an item count.
A fixed item count doesn't actually protect a user's device — a hundred one-line summaries and a hundred image-heavy articles are wildly different sizes on disk. A byte budget does, regardless of what each entry contains.
lib/flux.ts (excerpt)
1flux.register({
2 channel: 'blog_post_detail',
3 table: 'blog_posts',
4 idbKey: 'blog_post_detail',
5 ttl: 'long',
6
7 // LRU — for unbounded collections (detail pages, product pages,
8 // anything with a long tail). Capped by a byte budget rather than
9 // an item count, so cache size stays predictable regardless of
10 // how large individual entries are.
11 ingestionType: 'LRU',
12 cacheStrategy: {
13 type: 'LRU',
14 maxBytes: 15 * 1024 * 1024, // 15MB budget for this channel
15 trackAccessTime: true, // protect actively-read entries from eviction
16 },
17
18 store: blogDetailStoreAdapter,
19 event: 'UPDATE',
20})
When an LRU partition's budget fills up, Flux evicts the least-recently-accessed entries first — never the entry you just wrote — until the partition is back under budget. Setting trackAccessTime: true means an entry the user is actively reading gets its "last accessed" clock refreshed, so it isn't evicted out from under them mid-read even if it's technically the oldest entry by write time.
If you don't set a budget

An LRU channel with no maxBytes configured falls back to a sane 10MB default. Whatever you configure — or whatever the default resolves to — is automatically capped at a hard ceiling, so a misconfigured budget can never balloon into a real problem on a visitor's device. Mobile browsers, Safari especially, are noticeably less forgiving about large origin storage usage than desktop, so keeping LRU budgets modest is worth doing deliberately rather than leaving to chance.

6

Every app's cache is isolated

Set storagePrefix once, in your engine config, and every IDB entry Flux writes for this app is namespaced under it. This is what lets two independent Flux-powered surfaces — say, a public marketing site and an authenticated dashboard — share a browser origin without their caches ever colliding or overwriting each other.
lib/flux.ts (excerpt)
1const flux = createFlux({
2 adapter: createSupabaseAdapter(supabase),
3
4 // Namespaces every IDB store this engine creates. Two Flux-powered
5 // apps on the same origin (e.g. a marketing site and an embedded
6 // dashboard) never collide, even though they share one browser's
7 // storage for that origin.
8 storagePrefix: 'my-app',
9})
7

Images are handled separately from data

Images inside cached payloads aren't stored as ordinary blob URLs — that approach breaks in Safari specifically, where blob URLs silently go stale after a tab is backgrounded and restored, leaving broken images behind with no error to catch. Flux stores image data itself on disk and regenerates a fresh, valid URL automatically whenever a tab becomes visible again. If you're rendering cached images, this happens transparently — there's nothing extra to configure for it to work correctly across tab switches and device sleep/wake cycles.
What to reach for, and when

A single object or bounded array (homepage content, pricing, a team list) → ingestionType: 'SNAPSHOT' or 'COLLECTION_ALL', no cacheStrategy needed.

An unbounded, growing collection (articles, products, user-generated content) → ingestionType: 'LRU' with an explicit maxBytes.

Data that should never trigger a redundant refetch → pair a shorter ttl with a realtime adapter (see core-concepts/sync-anchors) rather than lengthening the TTL — a connected realtime channel already avoids the refetch entirely, regardless of how short its TTL is.

See core-concepts/sync-anchors for how reconnect catch-up decides what changed while you were away, core-concepts/activity-bus for how Flux avoids racing a refetch against an active realtime stream, core-concepts/data-ingestion-strategies for the full breakdown of all four ingestion types, and core-concepts/offline-queue for how writes — not just reads — are made resilient on top of this same local cache.