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=redis

If 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:

  1. A local change (via store.onChange) publishes { origin: replicaId, event } to the channel voltro:changes.
  2. Every replica subscribes. On a message whose origin is not this replica, it injects the change event into the local store emitter — the same injectExternalChange seam the postgres LISTEN/NOTIFY consumer uses.
  3. 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.

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 'mssql'. A write on one replica
will NOT reach clients on other replicas. Add @voltro/plugin-broadcast (Redis / NATS) to close the gap…

When BOTH a native path and the broadcast plugin are wired (e.g. postgres + broadcast), both stay active — the own-origin skip dedups, so there's no double-emit. The native path is the primary; the bus is harmless redundancy.

Where it lives in the codebase

  • voltro/packages/plugin-broadcast — the plugin, the BroadcastProvider contract, the redis / nats / memory providers, attachBroadcastBus.
  • voltro/packages/database/src/dataStore.tsDataStore.injectExternalChange (the cross-instance seam).
  • voltro/packages/cli/src/dev.ts wireBroadcastBus() — 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).