# Sync Backplane

> 

<important>

Experimental! The `sync` option and `crossws/sync` API may change between 0.x versions.

</important>

crossws [pub/sub](/guide/pubsub) 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.

```js
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](#redis-client-channel-connector)).
- **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):

```js
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

<table>
<thead>
  <tr>
    <th>
      Driver
    </th>
    
    <th>
      Backplane
    </th>
    
    <th>
      Reach
    </th>
    
    <th>
      When to use
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <a href="#redis-client-channel-connector">
        <code>
          redis
        </code>
      </a>
    </td>
    
    <td>
      Redis pub/sub
    </td>
    
    <td>
      Multi-region / multi-host
    </td>
    
    <td>
      The general-purpose choice for a real cluster.
    </td>
  </tr>
  
  <tr>
    <td>
      <a href="#pgsql-client-channel-connector">
        <code>
          pgsql
        </code>
      </a>
    </td>
    
    <td>
      Postgres <code>
        LISTEN
      </code>
      
      /<code>
        NOTIFY
      </code>
    </td>
    
    <td>
      Multi-region / multi-host
    </td>
    
    <td>
      You already run Postgres and would rather not add Redis (payloads must stay under 8 KB).
    </td>
  </tr>
  
  <tr>
    <td>
      <a href="#cluster-channel">
        <code>
          cluster
        </code>
      </a>
    </td>
    
    <td>
      Node.js <code>
        cluster
      </code>
      
       IPC
    </td>
    
    <td>
      Single host (forked processes)
    </td>
    
    <td>
      Multiple processes on one host (Node <code>
        cluster
      </code>
      
       / PM2) without a network broker.
    </td>
  </tr>
  
  <tr>
    <td>
      <a href="#broadcastchannel-channel">
        <code>
          broadcastChannel
        </code>
      </a>
    </td>
    
    <td>
      <code>
        BroadcastChannel
      </code>
    </td>
    
    <td>
      Single process (worker threads)
    </td>
    
    <td>
      Local fan-out, tests, in-process worker threads.
    </td>
  </tr>
</tbody>
</table>

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.

<note>

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](/adapters/cloudflare#sync-across-instances).

</note>

## Sync Drivers

### `redis({ client, channel, connector? })`

Works out of the box with both [ioredis](https://github.com/redis/ioredis) and [node-redis](https://github.com/redis/node-redis). The client flavor is auto-detected; pass `connector: "ioredis" | "node-redis"` to override detection.

<CodeGroup>

```js [ioredis]
import { redis } from "crossws/sync";
import Redis from "ioredis";

const ws = nodeAdapter({
  hooks,
  sync: redis({ client: new Redis(), channel: "my-app" }),
});
```

```js [node-redis]
import { redis } from "crossws/sync";
import { createClient } from "redis";

const client = await createClient().connect();

const ws = nodeAdapter({
  hooks,
  sync: redis({ client, channel: "my-app" }),
});
```

</CodeGroup>

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.

<note>

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.

</note>

### `pgsql({ client, channel, connector? })`

Relays over PostgreSQL [`LISTEN`/`NOTIFY`](https://www.postgresql.org/docs/current/sql-notify.html) — handy for clusters that already run Postgres and would rather not add Redis. Works with both [node-postgres](https://github.com/brianc/node-postgres) (`pg`) and [postgres.js](https://github.com/porsager/postgres); the flavor is auto-detected, with `connector: "pg" | "postgres.js"` to override.

<CodeGroup>

```js [node-postgres]
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" }),
});
```

```js [postgres.js]
import { pgsql } from "crossws/sync";
import postgresjs from "postgres";

const sql = postgresjs();

const ws = nodeAdapter({
  hooks,
  sync: pgsql({ client: sql, channel: "my-app" }),
});
```

</CodeGroup>

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

<important>

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.

</important>

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`](https://nodejs.org/api/cluster.html) 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`](#broadcastchannel-channel) leaves: its registry is per-process and silently won't sync across forks. For multiple hosts or regions you still want [`redis`](#redis-client-channel-connector) or [`pgsql`](#pgsql-client-channel-connector).

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

```js
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`](#cluster-channel); across hosts or regions use [`redis`](#redis-client-channel-connector) or [`pgsql`](#pgsql-client-channel-connector). (Deno Deploy is the exception — its `BroadcastChannel` spans isolates.)

```js
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:

```ts
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.
