Live data from the database.
With no wires.
The hardest part of a real-time app is keeping the client in sync with the database. Voltro makes it the easy part: declare a query, subscribe with one hook, and every write fans out to every connected client over WebSocket. No polling. No refetch. No cache invalidation logic to forget.
// queries/posts.list.query.ts
import { defineQuery } from '@voltro/protocol'
import { Schema } from 'effect'
export const listPosts = defineQuery({
name: 'posts.list',
source: 'posts', // ← drives auto-invalidation
input: Schema.Struct({}),
output: Schema.Array(Post),
})
// queries/posts.list.query.server.ts
import { database } from '../database/schema'
export default (_input, _ctx) =>
database.posts.orderBy('createdAt', 'desc').limit(100)Reactivity is a runtime feature, not a plugin.
Every Voltro app gets these primitives by default. No extra package to install, no opt-in flag — turn off `tenant()` on a table and it still pushes; turn off the source: declaration and only the manual cache-bust path drops.
One hook, live data
useSubscription(api, tag) opens a WebSocket subscription cached by (api, tag, input). Two components asking for the same data share one upstream connection.
Postgres LISTEN/NOTIFY native
On Postgres, change events ride the database's built-in pub/sub. No Redis required. Other dialects use binlog CDC (MariaDB) or the @voltro/plugin-broadcast bus (MySQL, MSSQL).
Auto-optimistic patches
Declare `target` on a mutation; the client pre-applies the patch to every active subscription that matches. Server delta replaces the optimistic row when it lands. Failures auto-revert.
Tenant-scoped by default
When a table carries the tenant() mixin, the runtime AND-merges the tenant predicate into every subscription. Cross-tenant changes never wake the wrong subscriber.
Per-field relevance filter
The dispatcher tracks which columns each subscription depends on (projection + predicate + order + every join key). A write that touches an unrelated column skips re-query entirely — no SQL round-trip, no delta.
Computed + eager-loaded
Return a shaped value with relations: useSubscription gets the SAME object the server returns, fully typed, with eager-loaded children. The reactivity re-runs the whole shape on any change to the source table set.
One feature, three files.
// database/schema.ts
import { table, text, timestamp } from '@voltro/database'
export const posts = table('posts', {
body: text(),
createdAt: timestamp().defaultNow(),
})That is the whole loop. The query names the table it reads, the screen names the query, and the runtime connects the two — so a write anywhere reaches this component with nothing in between for you to maintain.
What the same screen costs elsewhere.
// The shortest CORRECT version elsewhere.
const KEY = ['posts'] // 1. a cache key
function usePosts() {
const qc = useQueryClient()
const q = useQuery({ queryKey: KEY, queryFn: fetchPosts })
useEffect(() => { // 2. a socket
const ws = new WebSocket(WS_URL)
ws.onmessage = (e) => {
const msg = JSON.parse(e.data)
// 3. an invalidation rule that must name the SAME key
if (msg.table === 'posts') qc.invalidateQueries({ queryKey: KEY })
}
return () => ws.close()
}, [qc])
return q
}const { data: posts } = useSubscription('app', 'posts.list')The left column is not a strawman — it is the shortest correct version: a cache key, a socket, an effect to join them, and an invalidation rule that has to name the same key the query does. Every line of it is a place the two can disagree.
What happens between a write and a re-render.
- 1
A mutation commits
The write goes through the store, inside a transaction, with the acting subject and the tables it touched recorded as it lands.
- 2
The database announces the change
On Postgres and MySQL the change feed is the database's own — no polling loop, and no second source of truth that can fall behind the first.
- 3
The runtime matches it to open queries
Each live subscription declared the table it reads. The match is that declaration, which is why there is no cache key to keep in step with anything.
- 4
Every affected screen re-renders
The delta rides the socket that screen is already holding. Across replicas the fan-out is fleet-wide, so it does not matter which process took the write.
Reactive queries, in depth.
What does a reactive query actually replace?
The usual stack for keeping a screen fresh: a fetch, a cache key, an invalidation rule, a refetch trigger, a polling interval, and an effect that ties them together. A reactive query replaces all of it with a subscription that stays open — the server pushes when the data changes, and the component re-renders.
The declaration is a query descriptor with an input schema, an output schema and the tables it reads. The client calls it with one hook. Nothing in between is yours to maintain: no query keys to keep unique, no manual invalidation to remember after a mutation, no stale-time to tune.
That matters most where the usual approach quietly fails. A second user edits a row, a background job finishes, another service writes to the database — none of those are events the browser knows to refetch after. A subscription driven by the database's own change feed sees all three.
How does the data get to the browser?
One WebSocket carries every subscription. The first message is a full snapshot; after that the server sends id-keyed delta patches with an ordering array, so a list of a thousand rows costs one changed row's worth of traffic rather than a thousand.
Pure reorders emit no operations, no-ops are suppressed, and an error in one subscription is isolated so a single bad query cannot stall its siblings on the same socket. Those are the details that decide whether a live UI is pleasant or janky at real data volumes.
The same JSON envelope also powers a one-shot HTTP path that drains a query to its first snapshot — which is what a server-rendered page's loader uses for first paint, through the same authentication middleware. First render and live updates read one contract, not two.
What makes updates feel instant?
Optimistic patches, derived rather than written. A mutation declares which table and operation it targets, so the client can patch the cached list before the round trip completes and reconcile when the server answers. You do not hand-write an optimistic updater per mutation, and you cannot forget to roll one back.
You can override the derived patch where the default is wrong, or turn it off entirely for a mutation whose effect is not a simple insert, update or delete. The point is that the common case costs nothing and the uncommon case is still expressible.
Because the reconciliation is the same delta path as any other change, an optimistic patch that turns out wrong is corrected by the server's own broadcast rather than by bespoke rollback code.
Does this survive a load balancer?
On Postgres and MySQL/MariaDB, yes, natively: the change feed is fleet-wide, so a write on any replica reaches subscribers on every replica. That is the property most home-grown reactive layers lack, and the one that turns a working demo into a broken deployment the day a second container starts.
For dialects without a cross-instance feed — SQLite, or SQL Server where you want an explicit bus — a broadcast plugin fans change events over Redis or NATS. It is additive to the in-process emit, so local reactivity keeps working if the broker is briefly unavailable.
Fan-out is tenant-scoped: a subscriber only wakes for writes inside its own tenant, so a busy neighbour does not push work onto every other tenant's sockets.
What one subscription gives you.
| Concern | How it is handled |
|---|---|
| Transport | One WebSocket for every subscription; snapshot first, then id-keyed delta patches. |
| Change source | The database's native feed — so a write from any client or service wakes the right subscriptions. |
| Invalidation | Derived from the query's declared source tables; no cache keys and no manual invalidation. |
| Optimism | Patches derived from a mutation's declared target, overridable or switchable off per mutation. |
| Multi-replica | Native fleet-wide on Postgres and MySQL/MariaDB; Redis or NATS elsewhere via a plugin. |
| First paint | A one-shot path drains the same query to a snapshot for server-rendered loaders. |
Frequently asked questions
Is this just polling behind a nicer API?
No. The server is notified by the database's own change feed and pushes a delta over an open WebSocket. There is no interval, and a change made outside your application — a raw SQL client, another service — reaches subscribers the same way an in-app mutation does.
Do I still need a client cache library?
No, and running one alongside is usually counterproductive: two caches with different invalidation stories is how a screen ends up showing two ages of the same row. The subscription cache is the cache, and it is invalidated by the change stream rather than by heuristics.
What happens when the connection drops?
The client reconnects with backoff and re-subscribes, presenting the last revision it materialised. Inside the resume window the server replays only the deltas that were missed and the stream continues on the same revision line; outside it — or whenever anything is in doubt — the stream opens with a fresh snapshot, so a reconnect can never leave the UI holding a partially applied history. Connection status is exposed as a value you can render rather than something you have to infer.
Is it efficient with large lists?
Updates are id-keyed deltas, so the cost of a change is the changed rows, not the list. Pure reorders emit no operations at all, and there is a windowed variant for lists long enough that you only want a slice in memory.
Can I use reactive queries with server-side rendering?
Yes. A loader drains the same query to its first snapshot through a one-shot path with the same auth middleware, so the server-rendered HTML and the live subscription that takes over after hydration read one contract.
The thing you would have had to build.
Without reactive primitives, a real-time list view is a four-system integration: a database, a change-feed (LISTEN/NOTIFY, CDC, polling), a pub/sub bus (Redis, NATS), and a client cache (TanStack, SWR). Each system has its own retry semantics, its own auth story, its own observability gap, its own deploy rollback. Voltro wires them all to ONE invariant — the source: declaration on the query — and gives you reactive lists for free.
The control loop, in one sentence:
A mutation lands in Postgres → the runtime catches the change via LISTEN/NOTIFY → the matcher walks every open subscription whose source set includes the changed table → relevance pre-filter compares the mutated columns to each subscription's dependency set → matching subscriptions re-query in batch → the diff (snapshot or delta) ships to the client over WebSocket → useSubscription's data prop updates and React re-renders.
Reactivity composes with the rest of the runtime.
Mutations
Auto-optimistic patches on every subscription with a matching source. Transactional writes. Typed errors.
Multi-tenancy
The runtime AND-merges the tenant predicate into every subscription. Cross-tenant changes never wake the wrong subscriber.
Schema DSL
The .reactive() mixin opts a table into the matcher engine. Composes with audit(), tenant(), and your own mixins.
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.