Sync Backplane

Experimental! The 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. Pass onError to 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

DriverBackplaneReachWhen to use
redisRedis pub/subMulti-region / multi-hostThe general-purpose choice for a real cluster.
pgsqlPostgres LISTEN/NOTIFYMulti-region / multi-hostYou already run Postgres and would rather not add Redis (payloads must stay under 8 KB).
clusterNode.js cluster IPCSingle host (forked processes)Multiple processes on one host (Node cluster / PM2) without a network broker.
broadcastChannelBroadcastChannelSingle 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.

On Cloudflare the model is different — a single Durable Object is already cluster-global, so a backplane is only relevant for multi-instance sharding or the fallback path. See Cloudflare → Sync across instances.

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" }),
});

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.

On a dropped connection, ioredis automatically re-subscribes its channels; node-redis does not restore subscriptions the same way, so after a transient outage a node-redis-backed instance may stop receiving relayed messages until the client reconnects and re-subscribes. Prefer ioredis if resilience to flaky connections matters.

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" }),
});

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).

Pass a dedicated 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. The envelope.id === id guard is what prevents it.
  • Reuse the envelope helpers unless your transport carries structured data natively. BroadcastChannel, for example, can pass { id, msg } (binary Uint8Array and all) straight through, so its built-in driver skips the JSON/base64 envelope entirely and filters on id directly.