Caching is easy.
Invalidation ships with it.

Every cache library hands you `get` and `set` and leaves you the actual problem: knowing when the value stopped being true. Voltro already watches the database for change events, so a cached query result can be dropped the moment its table is written — by the same mechanism that pushes live updates to the browser.

plans.query.ts
TypeScript
// Read-through in one call — key, TTL, and the work on a miss
const plan = await ctx.cache.wrap('plan:' + id, '5m', () => fetchPlan(id))

// A cached QUERY needs no key work at all: it knows its table,
// and the change event that drives live subscriptions drops it
// the moment a write lands — on every replica.

What the layer covers.

On by default, nothing to provision

A memory cache is always there, so `ctx.cache` works from the first line of the first handler in a fresh project. Nothing to install locally, nothing to spin up for a test, and no branch in your code for "cache not configured".

Swap in Redis without touching call sites

Point the config at a Redis-compatible backend and every existing call keeps working — the same interface, now shared across replicas. Scaling from one container to a fleet is a config change, not a refactor of everywhere you cached something.

Query results invalidate themselves

A cached query knows the table it reads. When a write lands, the change event that already drives live subscriptions also drops the stale entries — so a mutation on one replica does not leave another serving yesterday's list. And where the change is not a table write, server code drops entries imperatively: revalidatePath, revalidateTable and revalidateTag reach every web replica, on every dialect — including the ones without change-data-capture.

One primitive for the read-through pattern

The wrap primitive turns "check the cache, compute on a miss, store, return" into one call with a key and a TTL. It is the shape everybody writes by hand and gets subtly wrong — usually by caching a failure, or by stampeding on expiry.

Inspect and flush without a redeploy

`voltro cache status` shows what the backend is actually doing, `flush` clears it, and `invalidate` targets a key or pattern. Diagnosing a stale-data report does not start with adding logging and shipping a build.

The same seam in dev and prod

The cache a handler reaches for is provided identically by `voltro dev` and `voltro serve`. That parity is deliberate — an earlier version had `ctx.cache` wired in development and quietly no-op in production, which is the exact class of bug the framework now guards both boot paths against.

A cached page with per-request holes

A page is cached by its render mode: static at build, isr pre-rendered then revalidated, ssr never. An isr page that exports ppr = true splits the difference — only the anonymous shell is cached, and the deferred fields stream in behind it on the same response, never entering the cache. Reading a credential in the shell half is a named error rather than silently-empty subject data baked into an artefact everyone gets, and revalidation purges the shell because the holes have nothing to invalidate.

Why invalidation belongs to the framework.

The framework already knows when the data changed

Application-level caching is guesswork because the app has to predict which writes affect which reads, in a codebase where both keep moving. Voltro does not have to predict: it consumes native change-data-capture to power live queries, so the fact "this table just changed" is already flowing through the runtime. Wiring that same fact to the cache turns invalidation from a discipline you maintain into a property of the system — and the failure it prevents, a fleet serving different answers from different replicas, is the one users report as "the app is haunted".

Invalidation, the part everyone gets wrong.

A key you must remember to bust
Manual
const key = `orders:${tenantId}:open`      // agreed in two places

const cached = await redis.get(key)
if (cached) return JSON.parse(cached)

const rows = await db.orders.where({ status: 'open' })
await redis.set(key, JSON.stringify(rows), 'EX', 60)

// …and somewhere else entirely, in the write path:
await redis.del(`orders:${tenantId}:open`)
//               ^ spelled again. Move either one and it goes stale.
A dependency the write knows about
Derived
export const openOrders = defineQuery({
  name:   'orders.open',
  source: 'orders',        // ← what it reads is what invalidates it
  cache:  { ttl: '60s' },
  output: Schema.Array(Order),
})

// A write to `orders` drops the entry. There is no key to spell
// twice, so there is no pair of spellings that can disagree.

A cache key is a string agreed between the code that fills it and the code that clears it, and the two drift the moment either moves. Deriving invalidation from the tables a query reads removes the agreement — a stale read becomes impossible rather than unlikely, and there is nothing left to remember.

Caching, in depth.

Why is invalidation the hard part, and who solves it here?

Because a cache library gives you `get` and `set` and leaves you the actual question: when did this value stop being true? Answering it means predicting which writes affect which reads, in a codebase where both keep moving, and being wrong is invisible until a user sees yesterday's data.

Voltro does not have to predict. It already consumes the database's change feed to drive live queries, so the fact "this table just changed" is flowing through the runtime. Wiring that to the cache turns invalidation from a discipline you maintain into a property of the system.

That is why a cached query needs no key management at all: the query declares the tables it reads, and the same change event that pushes a delta to the browser drops the stale entries — on every replica, not just the one that handled the write.

What do I get without configuring anything?

A memory cache that is always there. `ctx.cache` works from the first line of the first handler in a fresh project — nothing to install locally, nothing to spin up for a test, and no branch in your code for "cache not configured".

The read-through pattern is one call: a key, a time to live, and the work to do on a miss. That is the shape everybody writes by hand and gets subtly wrong — usually by caching a failure, or by letting every request recompute the same value the moment it expires.

When you outgrow one process, pointing the config at a Redis-compatible backend keeps every existing call site working. The interface does not change, so scaling from one container to a fleet is configuration rather than a refactor of everywhere you cached something.

How do I see and control what is cached?

`voltro cache status` reports what the backend is actually doing, `flush` clears it and `invalidate` targets a key or a pattern. Diagnosing a stale-data report does not start with adding logging and shipping a build.

Hit rates and cache behaviour ride the same metrics registry as everything else, so the dashboard and any exporter you add read one source. Two sources of truth about a cache is how a team ends up arguing about whether a fix worked.

Cache entries are tenant-aware because the change events that invalidate them carry the tenant. One tenant's write cannot serve another tenant a stale list — or, worse, a foreign one.

Is the cache the same in development and production?

Yes, and that parity is deliberate rather than incidental. An earlier version had `ctx.cache` wired in development and quietly a no-op in production: it worked locally, did nothing where it mattered, and nothing failed loudly enough to notice.

That class of bug — a capability wired into one boot path and not the other — has hit this framework repeatedly, so the cache is now provided by a shared builder both paths call, and a derived check fails the build when a new wiring reaches only one of them.

The lesson generalises past caching: when two paths need the same thing, extract the thing rather than remembering to wire it twice. A check that reads one file cannot see a second path it does not know exists.

What the layer handles.

Caching concerns and how each is handled
ConcernHow it is handled
Default backendMemory, always available — no provisioning locally and nothing to spin up for a test.
Shared backendA Redis-compatible store behind the same interface; a config change, not a refactor.
Query invalidationDerived from the query's declared tables and driven by the change stream, on every replica.
Read-throughOne call with a key and a TTL — the pattern everybody hand-writes and gets subtly wrong.
Operationsstatus, flush and targeted invalidate from the CLI, without shipping a build to investigate.
Dev/prod parityOne shared builder for both boot paths, with a derived check so a new wiring cannot reach only one.

Frequently asked questions

Do I need Redis to use the cache?

No. Memory is the default and is always available, which is what makes `ctx.cache` usable from the first handler in a fresh project. Redis matters when you have several instances and want them to share a cache.

Do I have to invalidate cached queries myself?

No. A query declares the tables it reads, and the change event that already drives live subscriptions drops the stale entries — including on replicas that did not handle the write. Manual invalidation stays available for values that are not query results.

Is it safe with multiple tenants?

Yes. The change events that invalidate cached results carry the tenant, so one tenant's write cannot leave another serving a stale or foreign list. Scoping is inherited rather than re-implemented at the cache layer.

How do I debug stale data?

`voltro cache status` shows what the backend is doing, `invalidate` targets a key or pattern, and hit rates ride the same metrics registry the dashboard reads. You investigate a running system rather than adding logging and redeploying.

Does the cache behave the same in production?

Yes, and it is guarded. An earlier version was wired in development and a silent no-op in production — the exact class of bug the framework now prevents by building the cache through one shared builder both boot paths call, with a derived check that fails when a wiring reaches only one.

What caching sits next to.

Open the framework. See it for yourself.

Every primitive on this page is in the framework today. Clone the starter, run `voltro dev`, and have it on screen in two minutes.