Multi-replica reactivity
Cross-replica change fan-out behind a load balancer — native LISTEN/NOTIFY (postgres) and binlog CDC (mysql + mariadb), plus @voltro/plugin-broadcast (Redis / NATS) to close the gap for every other dialect.
Behind a load balancer, each live client WebSocket lives on exactly one replica. Replica A holds clients 1–100, replica B holds 101–200, and so on. A mutation runs on whichever replica received the request, and inline-emits the change to that replica's local subscribers. The other replicas never learn of the write — so their clients go stale until they refetch.
This page is about closing that gap: making a write on any replica reach the live subscriptions on every replica.
The three tiers
How a change crosses replica boundaries depends on the dialect:
| Tier | Dialects | Mechanism | Cross-replica? |
|---|---|---|---|
| Native DB fan-out | postgres | LISTEN/NOTIFY — the database IS the bus |
✓ |
| Native DB fan-out | mysql · mariadb | binlog CDC (ROW format) — every replica tails the binlog | ✓ |
| Pub/sub bus | mssql · cockroachdb · planetscale · azure-sql | @voltro/plugin-broadcast (Redis / NATS) |
✓ (when wired) |
| Single-instance | sqlite · memory |
in-process only | n/a (single process by design) |
Postgres (LISTEN/NOTIFY) and MySQL/MariaDB (ROW-format binlog CDC) have a native cross-instance path: every replica learns of a write directly from the database. For the other SQL dialects there is no native cross-instance path — reactivity silently degrades to single-instance. @voltro/plugin-broadcast closes that with a pub/sub message bus. (PlanetScale is MySQL-based but Vitess doesn't expose a raw binlog to external readers, so it stays on the bus tier.)
@voltro/plugin-broadcast
A first-class, provider-pluggable plugin that fans out app-mutation change events to every replica over a pub/sub broker. Two backends ship: Redis and NATS.
// app.config.ts
import { broadcastPlugin } from '@voltro/plugin-broadcast'
export default {
type: 'api' as const,
name: 'api',
plugins: [
broadcastPlugin(), // reads BROADCAST_URL / BROADCAST_PROVIDER / REDIS_URL from env
],
}# Redis (RESP pub/sub)
BROADCAST_URL=redis://localhost:6379
# or NATS
BROADCAST_URL=nats://localhost:4222
# explicit provider override (otherwise inferred from the URL scheme)
BROADCAST_PROVIDER=redisIf BROADCAST_URL is unset the plugin falls back to an in-process memory provider — useful in tests, but single-process: a real cross-replica bus needs a broker URL. The boot banner warns when this happens.
You can also pass a pre-built provider or an explicit name:
import { broadcastPlugin, redisProvider } from '@voltro/plugin-broadcast'
broadcastPlugin({ provider: redisProvider({ url: process.env.REDIS_URL! }) })
broadcastPlugin({ provider: 'nats', url: 'nats://nats:4222' })ioredis and nats are optional dependencies — installed only for the backend you use. The plugin dynamically imports the driver, so an app on memory never pulls either.
Mechanism — additive, not a replacement
The bus is additive to the inline emit path. Local reactivity must survive a broker outage:
- A local change (via
store.onChange) publishes{ origin: replicaId, event }to the channelvoltro:changes. - Every replica subscribes. On a message whose
originis not this replica, it injects the change event into the local store emitter — the sameinjectExternalChangeseam the postgresLISTEN/NOTIFYconsumer uses. - The writer's own broadcast is skipped (it already inline-emitted locally) — so there is no double-emit and no dedup table.
Because the inline path is never removed, a broker outage degrades cross-replica fan-out only — local reactivity keeps working, and the framework logs a warning. The bus reconnects when the broker returns.
When the connection drops
Every cross-replica mechanism here rides a connection, and a connection that dies quietly is worse than one that fails loudly: the app keeps serving, the clients keep their sockets, and their live queries simply stop updating. So each path is required to notice, recover, and then say that it lost something.
A hole is never patched — it is re-derived. None of these transports keeps a log. Postgres queues nothing for a listener that is not there; Redis and NATS pub/sub retain nothing at all. So there is nothing to replay, and the only complete recovery is to re-run every live query. That is safe precisely because a live query is idempotent, and it is what the framework does on every one of the events below:
| What happened | How it is noticed | What you see |
|---|---|---|
The postgres LISTEN connection died (failover, proxy, pg_terminate_backend) |
A heartbeat sent through the pool goes unanswered on the LISTEN stream | cdc: reconnecting → cdc: reconnected, then every live query refreshes |
| A peer's serial jumped — the broker dropped messages | Per-origin serial accounting; the count is exact | broadcast: missed N change(s) from … |
| This replica could not subscribe at boot (the broker was restarting) | The subscribe is retried in the background | broadcast: could not subscribe → broadcast: subscribed, then a refresh |
| The Redis or NATS transport re-dialled underneath us | The driver's connection lifecycle | broadcast: transport disconnected → a refresh on reconnect |
Two consequences worth knowing:
- A broker that is down at boot does not stop the boot. The replica starts, serves, keeps local reactivity, and joins the bus when the broker returns. A crash loop across the whole fleet is the wrong answer to a broker restart — which is exactly when every replica is dialling at once.
- A replica that restarts under a stable name is recognised as a new process. A StatefulSet pod keeps its
POD_NAME, andVOLTRO_REPLICA_IDis stable by definition, so the name alone cannot tell a restart from a continuation. Each publish carries a per-process epoch so peers reset their watermark instead of quietly ignoring the new process's serials.
The postgres heartbeat is idle-only: any traffic on the channel — including another replica's heartbeat — counts as proof the connection works, so a busy channel never pays for one and a fleet pays roughly one probe per idle window however many replicas it has. Tune it with:
| Variable | Default | Meaning |
|---|---|---|
VOLTRO_CDC_HEARTBEAT_MS |
20000 |
Silence on the channel before a probe is sent |
VOLTRO_CDC_HEARTBEAT_TIMEOUT_MS |
10000 |
How long an unanswered probe may go before the consumer is declared dead |
The honest caveat — app-mutation changes only
The bus carries changes that flow through ctx.store (the framework's mutation path). It does not capture out-of-band DB writes — a psql session, a cron job, or a second service writing the same database directly. Those changes never hit store.onChange, so they never reach the bus.
Only two mechanisms observe out-of-band writes:
- postgres
LISTEN/NOTIFY— the DB itself fires on any committed change (the framework's triggers fan out every write, regardless of who made it). - mysql / mariadb binlog CDC — the ROW-format binlog records every committed row change; both engines share one reader.
If your app shares its database with other writers and needs them to drive reactivity, choose postgres, mysql, or mariadb. If all writes go through the Voltro app (the common case), the broadcast bus is the right tool for non-native dialects.
What about polling a changelog table?
Polling is not built. It's documented here only as the absolute last resort.
A changelog table that every replica polls (SELECT … WHERE seq > :last) would observe out-of-band writes on any dialect — but at a cost the pub/sub bus avoids entirely: ~150 ms latency (poll interval) vs ~1 ms, constant DB load from every replica on every tick, and a changelog table to vacuum. Pub/sub beats it on every axis, is dialect-agnostic, and Voltro already runs Redis pub/sub for read-your-writes consistency. If you genuinely need out-of-band-write reactivity on a dialect that can't do LISTEN/NOTIFY or binlog, that's the signal to move to postgres — not to bolt on polling.
Boot banner
voltro dev / voltro start prints the resolved reactivity tier so voltro logs --tail 50 answers "how do writes cross replicas here" without reading source:
[voltro:dev] reactivity: cross-instance via native LISTEN/NOTIFY (postgres)
[voltro:dev] reactivity: cross-instance via @voltro/plugin-broadcast (redis) for dialect 'planetscale'
[voltro:dev] reactivity: cross-instance fan-out is OFF for dialect 'cockroach'. A write on one replica
will NOT reach clients on other replicas. Add @voltro/plugin-broadcast (Redis / NATS) to close the gap…
The line reports what is RUNNING, not what the dialect could run. It is derived from the store's resolved change scope, so a dialect that can carry changes natively but has capture switched off — CDC=0, or an app where every table is .nonReactive() — is reported as the gap it is, naming the cause rather than the dialect:
[voltro:dev] reactivity: cross-instance fan-out is OFF. 'mariadb' can carry changes natively
(binlog CDC) but change capture is not running — CDC=0, or every table is .nonReactive()…
Read that line, not the dialect, when you need to know whether the N² amplification below applies to you: without a native transport there is no second delivery to suppress.
When BOTH a native path and the broadcast plugin are wired (e.g. postgres + broadcast), both stay active — and they carry different things. The native path carries table changes to every replica. The bus carries reactivity channels (publishReactivity), which are not database writes and so have no native transport at all.
[voltro:dev] reactivity: native LISTEN/NOTIFY (postgres) carries table changes;
@voltro/plugin-broadcast (redis) carries reactivity channels
This page used to say the two paths were harmless redundancy because "the own-origin skip dedups". They were not. The own-origin skip only ever covered a replica's own publish coming back to itself — so a change the native transport had already delivered to every replica was re-published by every replica under its own origin, and each peer injected it again. N replicas turned one change into N² deliveries: every subscriber, every live-query wake, every plugin tap. At two replicas a *.subscribe.ts handler ran four times for one INSERT, twice per instance.
sqlite and the memory store behind replicas
Neither dialect has a cross-instance path, and neither can get one: a local database file and an in-process store are, by construction, this process's. Putting several replicas in front of one is not a reactivity gap — the replicas do not share a database at all, so each one is also reading its own data.
On a laptop that is the correct, normal configuration, which is why the framework said nothing about it for a long time. It now warns when it can see evidence of an orchestrator:
[voltro:serve] this app runs on 'sqlite' — a local database file — but POD_IP is set
(Kubernetes), which means several replicas. Neither dialect has ANY cross-instance
change capture: a client connected to replica A never sees a write made on replica B,
for every table, and each replica is also reading its OWN data…
The evidence is one of REPLICA_COUNT > 1, KUBERNETES_SERVICE_HOST, POD_IP,
POD_NAME, FLY_ALLOC_ID, FLY_MACHINE_ID, ECS_CONTAINER_METADATA_URI[_V4],
K_REVISION, CONTAINER_APP_REPLICA_NAME or RENDER_INSTANCE_ID. HOSTNAME,
NODE_ENV and PORT are deliberately not evidence — every single-instance
container sets those too, and a warning that fires on a laptop gets filtered out
before it reaches the deployment where it is true.
Two ways to silence it, and they are not equivalent:
REPLICA_COUNT=1— you are telling the framework there is exactly one process. This is the honest one, and it is the only positive evidence against that exists, so it outranks every platform signal.- Declaring
@voltro/plugin-broadcastwith a real provider — not because a bus fixes it (the replicas still have separate databases), but because declaring one means you have already thought about the question.
The real fix is postgres or mysql/mariadb: a shared database with a native cross-instance change path.
Where it lives in the codebase
voltro/packages/plugin-broadcast— the plugin, theBroadcastProvidercontract, the redis / nats / memory providers,attachBroadcastBus.voltro/packages/database/src/dataStore.ts—DataStore.injectExternalChange(the cross-instance seam).voltro/packages/cli/src/dev.tswireBroadcastBus()— tier selection + boot banner.
See also
- SQL dialects — the per-dialect parity table (CDC row).
- Read replicas — read routing + read-your-writes consistency (a different axis: which replica serves a read, not how a write fans out).