Sync Backplane
sync option and crossws/sync API may change between 0.x versions.crossws pub/sub is in-memory and local to one instance. When you run more than one instance — multiple regions, processes, or replicas behind a load balancer — a peer.publish("chat", ...) only reaches the peers connected to that instance. A subscriber connected elsewhere never sees it.
A sync adapter bridges that gap: it relays published messages between instances over a shared backplane (e.g. Redis pub/sub) and fans inbound messages out to local subscribers. You don't need to change your hooks — subscribe and publish keep working, now across the cluster.
import nodeAdapter from "crossws/adapters/node";
import { redis } from "crossws/sync";
import Redis from "ioredis";
const ws = nodeAdapter({
hooks,
sync: redis({ client: new Redis(), channel: "my-app" }),
});
Each driver takes a single options object. The channel name is required — it scopes the cluster, so unrelated servers don't silently bridge into each other.
crossws subscribes to the backplane automatically. On shutdown, call await ws.close() — it closes every connected peer and tears down the backplane in one step (leaving any Redis/Postgres client you passed in connected; its lifecycle stays yours). Any server you created (e.g. the http.Server) is also yours to close.
Delivery semantics
The cross-instance relay is best-effort and fire-and-forget — design your topics to tolerate that:
- At-most-once and unordered. A relayed message reaches remote subscribers at most once, and ordering across instances is not guaranteed. If you need to detect a gap, carry your own sequence/version in the payload rather than assuming a reliable stream.
- No replay or buffering. Messages published while an instance is disconnected from the backplane — or before it finishes subscribing on startup — are not queued; that instance simply misses them (see the ioredis vs node-redis reconnect note).
- Local delivery is independent.
peer.publish()always reaches local subscribers synchronously, even when the backplane is down. Only the cross-instance relay is best-effort. - Failures never throw into your
publish(). A backplane error is isolated so it can't crash the process. PassonErrorto observe a degraded backplane (for logging, metrics, or alerting):
const ws = nodeAdapter({
hooks,
sync: redis({ client: new Redis(), channel: "my-app" }),
onError(error, { stage }) {
// stage: "subscribe" (initial connect) | "publish" (relay out) | "delivery" (fan-in)
metrics.increment(`crossws.sync.error.${stage}`);
console.error("[crossws] sync error", stage, error);
},
});
Without onError, failures are logged to console.error.
Choosing a driver
| Driver | Backplane | Reach | When to use |
|---|---|---|---|
redis | Redis pub/sub | Multi-region / multi-host | The general-purpose choice for a real cluster. |
pgsql | Postgres LISTEN/NOTIFY | Multi-region / multi-host | You already run Postgres and would rather not add Redis (payloads must stay under 8 KB). |
cluster | Node.js cluster IPC | Single host (forked processes) | Multiple processes on one host (Node cluster / PM2) without a network broker. |
broadcastChannel | BroadcastChannel | Single process (worker threads) | Local fan-out, tests, in-process worker threads. |
All four ship in crossws/sync and have no third-party dependencies — redis and pgsql take a client you bring; cluster and broadcastChannel need nothing.
Sync Drivers
redis({ client, channel, connector? })
Works out of the box with both ioredis and node-redis. The client flavor is auto-detected; pass connector: "ioredis" | "node-redis" to override detection.
import { redis } from "crossws/sync";
import Redis from "ioredis";
const ws = nodeAdapter({
hooks,
sync: redis({ client: new Redis(), channel: "my-app" }),
});
import { redis } from "crossws/sync";
import { createClient } from "redis";
const client = await createClient().connect();
const ws = nodeAdapter({
hooks,
sync: redis({ client, channel: "my-app" }),
});
crossws derives a dedicated SUBSCRIBE connection from your client via duplicate() (for node-redis it also connect()s it), so a single connected client is all you pass in.
pgsql({ client, channel, connector? })
Relays over PostgreSQL LISTEN/NOTIFY — handy for clusters that already run Postgres and would rather not add Redis. Works with both node-postgres (pg) and postgres.js; the flavor is auto-detected, with connector: "pg" | "postgres.js" to override.
import { pgsql } from "crossws/sync";
import { Client } from "pg";
const client = new Client();
await client.connect();
const ws = nodeAdapter({
hooks,
sync: pgsql({ client, channel: "my-app" }),
});
import { pgsql } from "crossws/sync";
import postgresjs from "postgres";
const sql = postgresjs();
const ws = nodeAdapter({
hooks,
sync: pgsql({ client: sql, channel: "my-app" }),
});
Unlike Redis SUBSCRIBE, Postgres LISTEN doesn't block the connection, so no duplicate connection is needed (for node-postgres the same client both listens and notifies; postgres.js reserves its own dedicated connection internally).
Client, not a Pool. Pool connections rotate per query, so a LISTEN lands on a backend that's then returned to the pool and notifications never reach a stable listener. A Pool is detected and rejected at construction. For the same reason, give this driver its own client rather than sharing one across two pgsql() instances on the same channel — closing one issues UNLISTEN and silences the others.Two transport limits to keep in mind: the channel name maps to a Postgres identifier, capped at 63 bytes (rejected at construction if longer), and a NOTIFY payload is capped at 8000 bytes (base64 inflates binary ~33%), so keep relayed messages small.
cluster({ channel })
Relays over Node.js cluster IPC — bridges the forked processes on a single host (Node cluster, or PM2 instances) without standing up a network broker. This is the gap broadcastChannel leaves: its registry is per-process and silently won't sync across forks. For multiple hosts or regions you still want redis or pgsql.
The driver runs in the workers; the primary needs a one-line relay because cluster workers can't message each other directly — IPC only flows between each worker and the primary, which rebroadcasts. Call setupPrimaryCluster() once in the primary (it's a no-op in workers, so guarding with cluster.isPrimary is optional):
import cluster from "node:cluster";
import { availableParallelism } from "node:os";
import { setupPrimaryCluster, cluster as clusterSync } from "crossws/sync";
if (cluster.isPrimary) {
setupPrimaryCluster();
for (let i = 0; i < availableParallelism(); i++) cluster.fork();
} else {
const ws = nodeAdapter({
hooks,
sync: clusterSync({ channel: "my-app" }),
});
// ... start your server
}
Binary payloads are base64-encoded, so default IPC serialization is enough — you don't need serialization: "advanced". Calling clusterSync() outside a forked worker (no process.send) throws on subscribe, surfacing the misconfiguration rather than silently not syncing.
broadcastChannel({ channel })
Bridges instances that share a BroadcastChannel registry. On Node.js, Deno and Bun that registry is scoped to a single process — it spans the main thread and its worker threads, but not separate OS processes (e.g. Node cluster/PM2 forks), which each get an isolated registry and silently won't sync. For forked processes on one host use cluster; across hosts or regions use redis or pgsql. (Deno Deploy is the exception — its BroadcastChannel spans isolates.)
import { broadcastChannel } from "crossws/sync";
const ws = nodeAdapter({
hooks,
sync: broadcastChannel({ channel: "my-app" }),
});
Writing a driver
Any transport that can fan a message out to your other instances can back a sync driver. A driver is a SyncAdapter: a factory crossws calls once per instance, passing a stable per-instance id. It returns a SyncDriver with three methods:
publish(msg) — called for every local peer.publish() / adapter.publish(). Relay msg to the other instances over your backplane. A msg is { namespace, topic, data }, where data is a string | Uint8Array.
subscribe(deliver) — called once on startup. Listen for messages from the other instances and hand each one to deliver, which fans it out to this instance's local subscribers.
close() (optional) — called when the adapter shuts down. Release any connection you opened.
Two things are your responsibility on the wire: suppress your own echo (most backplanes deliver a publisher its own messages) and preserve binary payloads (most transports are text-only). Both are handled by a small envelope that stamps the sender id and base64-encodes binary data. crossws exports the same encodeEnvelope / decodeEnvelope helpers its built-in drivers use, so you don't have to reimplement them:
import { encodeEnvelope, decodeEnvelope } from "crossws/sync";
import type { SyncAdapter } from "crossws/sync";
const mySync: SyncAdapter = ({ id }) => ({
subscribe(deliver) {
backplane.on("message", (raw) => {
const envelope = decodeEnvelope(raw);
if (!envelope || envelope.id === id) {
return; // ignore malformed messages and our own echo
}
deliver(envelope.msg);
});
},
publish(msg) {
backplane.send(encodeEnvelope(id, msg)); // stamps `id` so we can filter it back out
},
close() {
backplane.disconnect(); // optional: release the connection on shutdown
},
});
encodeEnvelope(id, msg) returns a JSON string; decodeEnvelope(raw) parses it back to { id, msg } (or undefined for anything malformed). Binary data is base64-encoded inside the envelope and restored on decode.
Notes:
- Skip the echo check and every publish is delivered twice locally — once from your own loopback, once from
deliver. Theenvelope.id === idguard is what prevents it. - Reuse the envelope helpers unless your transport carries structured data natively.
BroadcastChannel, for example, can pass{ id, msg }(binaryUint8Arrayand all) straight through, so its built-in driver skips the JSON/base64 envelope entirely and filters oniddirectly.