Ingestion Strategies

LRU ingestion

For detail pages capped at a byte budget — products, articles, anything that grows unbounded over time. Individually keyed per record, evicted oldest-first once the partition exceeds its configured budget.

Prerequisites
  • A store already registered via register() — see api/register
  • Familiarity with the other three types — see ingestion/snapshot, ingestion/collection-all, ingestion/paginated
1

Basic usage

Set ingestionType: 'LRU' and pair it with a cacheStrategy on any channel whose records are individually keyed and too numerous or open-ended to cache all at once.
typescript
1flux.register({
2 store: useArticleStore,
3 channel: 'blog_post_detail',
4 event: 'UPDATE',
5 idbKey: 'blog_post_detail',
6 ttl: 'long',
7 ingestionType: 'LRU',
8 cacheStrategy: {
9 type: 'LRU',
10 maxBytes: 5 * 1024 * 1024, // 5MB budget for this partition
11 trackAccessTime: true,
12 },
13 hydrateState: (store, data) => store.getState().setArticle(data),
14})
2

What qualifies as LRU

Detail pages where the total record count is unbounded — you don't know in advance how many products or articles will exist, and you don't want to cache all of them forever.
typescript
1// LRU is for detail pages — individually keyed records that can
2// grow unbounded over time and need a byte-budget ceiling rather
3// than being fetched and cached all at once.
4
5// Typical LRU channels:
6flux.register({ channel: 'products', ingestionType: 'LRU', /* ... */ })
7flux.register({ channel: 'blog_post_detail', ingestionType: 'LRU', /* ... */ })
8
9// Not LRU: a bounded list like 'team' or 'pricing' — those are
10// COLLECTION_ALL. LRU is specifically for the case where the full
11// set of records is too large or too open-ended to hold entirely.
3

Two key shapes in IDB

LRU is the only ingestion type that splits a single idbKey into two distinct shapes — a root heartbeat key and per-item data keys — and they must never be confused with each other.
typescript
1// LRU uses two distinct key shapes in IDB — this is the one thing
2// that makes it different from every other ingestion type:
3
4// Root heartbeat key — carries only a cachedAt timestamp, used by
5// the revalidation loop to check partition freshness in one read.
6// Never holds real data.
7// 'global:blog_post_detail' (or 'user:{userId}:blog_post_detail')
8
9// Per-item keys — the actual cached records, one per slug/id.
10// Identifier is item.slug if present, otherwise item.id.
11// 'global:blog_post_detail:introduction-to-flux'
12// 'global:blog_post_detail:some-other-slug'
13
14// Each per-item key has a matching lru_meta entry recording
15// lastAccessedAt and byteSize — used by eviction, never the
16// data envelope itself.
The root key is never data

The root key only ever holds a heartbeat envelope ({ data: true, cachedAt, ttl }) used by the revalidation loop to check freshness with a single read. It's excluded from eviction entirely — only per-item keys are eviction candidates.

4

Setting a byte budget

maxBytes is the field to set directly. Budget resolution and eviction accounting are computed per resolved (scoped) partition — a 'user'-scoped LRU partition's budget is tracked independently per tenant.
typescript
1// Budget resolution priority chain, per resolved (scoped) partition:
2
3// 1. maxBytes — set this. It's the primary field.
4// 2. maxEntries — rough ~20KB/entry conversion, back-compat only.
5// 3. Neither set — defaults to 10MB.
6
7// Whatever is configured or computed is clipped to a hard ceiling
8// of 50MB, with a console warning if clipping occurs — protects
9// against iOS Safari's aggressive eviction once origin storage spikes.
10
11cacheStrategy: {
12 type: 'LRU',
13 maxBytes: 5 * 1024 * 1024, // primary field — set this directly
14}
5

Dispatch skips the store for arrays

Unlike SNAPSHOT and COLLECTION_ALL, an LRU channel receiving an array payload never calls hydrateState with the whole array — each item is written to IDB individually, and detail pages read their own item directly on mount.
typescript
1// LRU dispatch differs from every other ingestion type at the
2// store-write step. When the payload is an array, dispatchToStore
3// skips the store mutation entirely — each item is written to IDB
4// individually instead, and a detail page reads its own item
5// directly via idbGet on mount.
6
7// IDB cold boot -> skipped at hydration — LRU roots are never
8// collectively hydrated, only the heartbeat
9// envelope lives at the root key
10// Bootstrap fetch -> dispatchToStore(reg, [...items], ..., generation)
11// -> each item written under {idbKey}:{slug}
12// Realtime event -> dispatchToStore(reg, event.data, ...)
13// -> single item, written under {idbKey}:{slug}
14
15// A component reading a single detail page calls idbGet directly
16// on mount rather than relying on the registered store's hydration.
6

Eviction and access tracking

When the partition exceeds budget after a write, the oldest entries by lastAccessedAt are evicted first, fire-and-forget, without blocking the write.
typescript
1// Eviction runs fire-and-forget after every write that pushes the
2// partition over budget — it never blocks the write from resolving.
3
4// 1. Sort all lru_meta entries in the partition by lastAccessedAt
5// ascending (oldest first).
6// 2. Delete data key + lru_meta key together, atomically, one by
7// one, accumulating recovered bytes.
8// 3. Stop once the running total falls back under budget.
9
10// Entries just written are always the most recently accessed and
11// are never targeted for eviction in the same pass that created them.
12
13// Reads matter too: idbGet calls touchLruAccessTime after a
14// successful read, refreshing lastAccessedAt without recomputing
15// byteSize — this protects actively-read entries from eviction
16// priority without an extra full write.
7

Oversized writes are rejected, not silently dropped

A single payload larger than the entire partition budget is refused outright, with a console warning — this is the one failure mode unique to LRU.
typescript
1// Before writing any LRU item, idbSet estimates its byte size and
2// checks it against the partition's resolved budget. If the single
3// payload exceeds the entire budget, the write is rejected outright
4// with a console warning — never silently written and left to
5// permanently blow the budget on every subsequent eviction pass.
6
7// This is the one LRU-specific failure mode SNAPSHOT and
8// COLLECTION_ALL never hit: a single oversized record is always the
9// newest item, so eviction (which only removes older entries) can
10// never bring the partition back under budget by removing it.
Watch the console in development

If you see a rejected-write warning, your maxBytes is too small for the data you're trying to cache — raise the budget rather than ignoring the warning, since the write silently never lands.

8

Choosing the right ingestion type

LRU is one of four. Reach for it the moment your data is individually keyed and open-ended rather than a single object or a small bounded array.
typescript
1// Picking the right ingestionType:
2
3// SNAPSHOT — one object. homepage, siteConfig, termsPage
4// COLLECTION_ALL — a bounded array, services, team, pricing
5// fetched entirely
6// LRU — detail pages capped products, articles
7// at a byte budget
8// PAGINATED — large datasets with (deferred, Phase 14)
9// virtual view windows
10
11// Defaults to COLLECTION_ALL if ingestionType is omitted entirely —
12// so LRU channels should always set it explicitly, alongside a
13// cacheStrategy.

For a single object, see ingestion/snapshot. For bounded arrays, see ingestion/collection-all. For configuring ingestionType and cacheStrategy alongside the rest of a registration, see api/register.