Core Concepts

IDB Engine & TTL

How Flux decides what to keep on disk, how it knows whether that data is still current, and what happens the instant it is not. 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: freshness is coverage, not a clock

Cached data is never deleted because time passed, and for a channel that realtime keeps current, time passing does not make it stale either. A channel is fresh when something has been watching it since it was last confirmed. A TTL is the fallback freshness policy for the channels nothing can watch. Expired data is still served instantly from disk; what happens next depends on whether this channel is covered, 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 mean, in milliseconds, once in your createFlux() config, and every registration 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.
lib/flux.ts (excerpt)
1flux.register({
2 channel: 'pricing_table',
3 // Realtime subscribes to a table. On a table based backend (Supabase),
4 // a registration without `table` has no realtime binding and is TTL-only.
5 table: 'pricing_table',
6 idbKey: 'pricing_table:latest',
7
8 // 'short' | 'medium' | 'long' — map these to real durations once,
9 // in your createFlux() config, and reuse them everywhere.
10 ttl: 'long',
11
12 ingestionType: 'COLLECTION_ALL',
13 diffBeforeUpdate: true,
14 event: 'UPDATE',
15 store: pricingStoreAdapter,
16})
A rough guide for picking a bucket: short for things that change often and where staleness is visible (live metrics, a queue count). medium for moderate churn (a notifications list, a team roster). long for data that rarely changes (pricing tables, site configuration, terms pages). The bucket matters most for channels realtime cannot cover, covered next.
2

Which channels realtime can cover

Each registration resolves to one of three freshness modes, from what your adapter can do and what the registration declares:

Delta. A realtime binding plus delta catch-up. Live events keep the channel current, and any gap (offline, a dropped socket) is closed by asking the backend what changed. A refetch is only ever the exception.

Realtime only. A realtime binding but no delta support on the backend. Live events keep it current, but a gap can only be closed by refetching the channel.

TTL only. Realtime cannot cover it: no adapter is configured, or the registration has no table on a backend where realtime subscribes by table. The TTL is the only freshness mechanism, and a refetch happens when it elapses.

Set table on every channel you want covered

On Supabase, realtime subscribes to a table. A registration without table is not silently subscribed to a table named after the channel. It is treated as TTL-only, and a debug message says so. If a channel you expected to be live keeps refetching when its TTL elapses, this is the first thing to check.

3

A covered channel is never stale because time passed

Alongside every cached channel, Flux persists a small coverage record: the moment the disk copy was last confirmed current, and whether a live connection has been watching it since. While a leader tab holds an intact live subscription for the channel and is proving it with regular heartbeats, the channel is covered. A covered channel is not stale when its TTL elapses. Any change would have arrived as a realtime event and been applied, so asking the server again would just repeat a question realtime has been answering continuously.
A quiet channel makes the point. A terms page that has not changed in a month, viewed by someone who stayed connected the whole time, is just as fresh on day thirty as on day one, and it costs no requests to keep it that way. The coverage record, not the age of the cache entry, is what says so.
Why this matters

A cache that treats every elapsed TTL as stale would refetch data it already knew was correct. Flux treats "something has been watching this channel since it was confirmed" as its own form of freshness, separate from the TTL clock, so your backend is never asked a question it already answered.

4

What happens when you go offline and come back

Going offline is a different state from realtime being down, and Flux treats them differently. Offline, nothing can be fetched, so nothing is attempted: the app serves from disk, and the moment live coverage ended is recorded and persisted. That record survives closing the tab while offline.

Back online, realtime comes up. The leader asks the backend what changed since the channel was last confirmed. Rows that come back are applied. If the backend answers that nothing changed, the channel is current as of that answer and no refetch happens. A confirmed empty answer is a real answer, not a missing one.

The backend cannot answer. If the gap is older than the backend's changelog reaches, or the catch-up failed or could not be verified, Flux refetches that one channel. It never marks it current on a guess, and one failed channel never holds up the others.

Realtime is degraded. After repeated connection failures, Flux refetches the channels whose data predates the gap once, while realtime keeps retrying in the background. After that, only the TTL decides.

Realtime is blocked by a quota or credentials. The channel behaves as TTL-only while realtime retries on a slow backoff.

Realtime is still connecting. Flux waits for the next signal. There is no timer that gives up after a fixed number of seconds.

On a normal reconnect where realtime comes back, your app makes no redundant HTTP request at all. Only the catch-up over the live connection happens. A refetch is reserved for the cases where realtime genuinely cannot do that job.
5

Data is fetched once — global and per user

Both bootstrap scopes follow the same rule. The first time a channel's data is needed, it is fetched. After that, realtime and delta keep it current, and a repeat call to bootstrap() hydrates from disk instead of refetching whenever the channels are covered, or a gap is one a catch-up can close, or realtime simply is not up yet.
lib/flux.ts (excerpt)
1// Global data: fetched once, then kept current by realtime + delta.
2await flux.bootstrap({
3 endpoint: '/api/bootstrap/global',
4 scope: 'global',
5 map: { pricing_table: pricingStoreAdapter },
6})
7
8// Per-user data behaves the same way, scoped to that user. Logging out
9// wipes it, together with its coverage record.
10await flux.bootstrap({
11 endpoint: '/api/bootstrap/user',
12 scope: 'user',
13 userId: user.id,
14 map: { my_projects: projectsStoreAdapter },
15})
Several tabs opening at once, a React strict-mode double mount, or a fast reload all queue on a lock. The tab that wins fetches and records coverage before releasing it, and the others find the channels covered and skip the fetch. Offline, bootstrap simply hydrates from disk. User scope works the same way, scoped to that user. Logging out wipes the user's data and its coverage record together, so the next login fetches once.
6

What happens on every cold read

Every read from the local cache — first load, a tab reopening, a page revisit — resolves to exactly one of three outcomes:

Fresh. The data is covered, or within its TTL for a channel with no coverage. It is dispatched to your store immediately and nothing else happens.

Stale. The data exists but Flux cannot vouch for it. It is still dispatched immediately, so the UI is never empty. onStale fires for a channel with no realtime coverage whose TTL has elapsed, for a channel with no coverage record yet whose TTL has elapsed (the first run after upgrading), and for a channel whose last catch-up came back too old or failed. It does not fire while offline, because there is nothing to act on, and it does not fire just because a covered channel's TTL elapsed.

Missing. Nothing is cached yet. Your store's initial or loading state is left as is and the UI waits for the network, for the very first visit only.

lib/flux.ts (excerpt)
1flux.register({
2 channel: 'dashboard_metrics',
3 table: 'dashboard_metrics',
4 idbKey: 'dashboard_metrics:latest',
5 ttl: 'short',
6
7 ingestionType: 'COLLECTION_ALL',
8 diffBeforeUpdate: true,
9 event: 'UPDATE',
10 store: dashboardStoreAdapter,
11
12 // Fires when Flux cannot guarantee the cached data is current:
13 // - the channel has no realtime coverage and its TTL elapsed, or
14 // - a catch-up could not prove it current (too old / failed).
15 // It does NOT fire just because a covered channel's TTL elapsed, and it
16 // does not fire merely because realtime connected.
17 onStale: () => {
18 console.log('Cached dashboard data could not be proven current')
19 },
20})
Reading from disk never restamps an entry as fresh. One store failing to read — a corrupted entry, an unexpected shape — never blocks any other store from loading. Regardless of how many channels are registered or how slow the device's disk is, there is a hard ceiling on how long your app waits for cached data before rendering anyway: a couple of seconds, worst case.
7

Two data shapes, two caching strategies

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 an LRU (least recently used) partition capped by a byte budget rather than an item count.
A fixed item count does not actually protect a user 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 is not evicted mid read even if it is technically the oldest entry by write time. Realtime and catch-up rows only refresh entries the user already has cached; they never create entries for items nobody opened.
If you don't set a budget

An LRU channel with no maxBytes 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 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.

8

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. Per-user data, and each user's coverage record, live under that user's own namespace inside the prefix, which is why logging out can remove exactly one user's data and nothing else.
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})
9

Coverage only advances after the disk write succeeds

Disk is the source of truth, so Flux never records a channel as confirmed until the data it describes has actually reached IndexedDB. If a write fails — storage is full, IndexedDB is unavailable in a private window, or an LRU entry exceeds its partition budget — the channel is not marked current, a full-storage failure calls your onStorageFallback handler, and the channel is simply fetched again when needed. A cache that claims to be current while the disk disagrees is worse than one that admits it is not.
10

Images are handled separately from data

Images inside cached payloads are not 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 with no error to catch. Flux stores image data on disk and regenerates a fresh, valid URL automatically whenever a tab becomes visible again. If you are rendering cached images, this happens transparently.
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 → give the registration a table and use a realtime adapter with delta support (see core_concepts/sync_anchors). The TTL then only governs what realtime cannot cover, and a covered channel is never refetched for age alone.

See core_concepts/sync_anchors for how coverage and reconnect catch-up decide what changed while you were away, core_concepts/activity_bus for how Flux tracks catch-up results and network and realtime health, 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.

Powered by Flux