Skip to content

Patterns

The router gives you one primitive: fan-out to everyone but the sender. Everything else is a convention in the payload. These are the conventions that keep reappearing, in the order you are likely to need them.

All examples assume a send(message) function and an onMessage(handler) subscription, as built in the quickstart.

Typed messages

Start here, always. A type field costs one key and buys you the ability to add message kinds without rewriting anyone's handler.

js
const handlers = {};
const on = (type, fn) => (handlers[type] = fn);

onMessage((data) => {
  let msg;
  try {
    msg = JSON.parse(data);
  } catch {
    return; // not ours — ignore rather than throw
  }
  handlers[msg?.type]?.(msg);
});

const emit = (type, body) => send(JSON.stringify({ type, ...body }));

Ignoring messages you do not recognise is important: channels are shared, and a stray page or an older version of your app may be sending things you have never seen.

Identity

The router has no notion of who anyone is, so give each client an id on startup. It exists only for the lifetime of the tab.

js
const CLIENT_ID = crypto.randomUUID();
const emit = (type, body) =>
  send(JSON.stringify({ type, from: CLIENT_ID, ...body }));

You do not need this to avoid your own echo — the router already does that — but you do need it to address a specific peer or to attribute state.

Request/response

Fan-out plus a correlation id gives you RPC. Useful for asking whoever is out there for the current state.

js
const pending = new Map();

const request = (type, body, timeoutMs = 5000) => {
  const id = crypto.randomUUID();
  return new Promise((resolve, reject) => {
    const timer = setTimeout(() => {
      pending.delete(id);
      reject(new Error(`no reply to ${type}`));
    }, timeoutMs);
    pending.set(id, (value) => {
      clearTimeout(timer);
      pending.delete(id);
      resolve(value);
    });
    emit(type, { ...body, id });
  });
};

on("reply", (msg) => pending.get(msg.id)?.(msg.body));

// responder side
on("get-state", (msg) => emit("reply", { id: msg.id, body: currentState() }));

The timeout is not optional. Nobody may be listening, and there is no error when that happens — silence is the only signal you get.

Note that every peer will answer a request, so the first reply wins and the rest are dropped by pending.delete. If that matters, have responders wait a short random delay and stay quiet if they see someone else answer first.

Late joiner state sync

Because there is no history, a client that joins late knows nothing. Ask.

js
const boot = async () => {
  try {
    applyState(await request("get-state", {}, 2000));
  } catch {
    // nobody home — we are the first, so we define the state
    applyState(initialState());
  }
};

This single pattern covers most of what people miss about persistence, and it degrades sensibly: with no peers you simply start fresh.

Presence

Heartbeat in, expire on silence.

js
const peers = new Map(); // id -> { lastSeen, meta }
const TIMEOUT = 6000;

setInterval(() => emit("here", { meta: { name: myName } }), 2000);
on(
  "here",
  (msg) => peers.set(msg.from, { lastSeen: Date.now(), meta: msg.meta }),
);

setInterval(() => {
  const cutoff = Date.now() - TIMEOUT;
  for (const [id, peer] of peers) {
    if (peer.lastSeen < cutoff) peers.delete(id);
  }
  render([...peers.values()]);
}, 1000);

// leaving politely makes departures instant instead of taking TIMEOUT
addEventListener("pagehide", () => emit("gone", {}));
on("gone", (msg) => peers.delete(msg.from));

Announce yourself immediately on connect as well as on the interval, so peers learn about you without waiting a full tick.

Last-write-wins shared state

For small shared objects where perfect consistency is not worth the complexity:

js
let state = {};
let version = 0;

const update = (patch) => {
  state = { ...state, ...patch };
  version = Date.now();
  emit("state", { state, version });
};

on("state", (msg) => {
  if (msg.version <= version) return; // stale or our own generation
  state = msg.state;
  version = msg.version;
  render(state);
});

Concurrent edits mean somebody's change disappears. That is an acceptable trade for cursors and toggles, and a bad one for documents.

CRDT transport

For real collaborative editing, do not invent the merge logic. A CRDT library handles convergence and only needs a way to move updates between peers — which is exactly what a channel is.

js
import * as Y from "yjs";

const doc = new Y.Doc();
doc.on("update", (update) => emit("y", { update: toBase64(update) }));
on("y", (msg) => Y.applyUpdate(doc, fromBase64(msg.update)));

// a joiner asks for the full document once, then stays in sync via updates
on(
  "y-request",
  () => emit("y", { update: toBase64(Y.encodeStateAsUpdate(doc)) }),
);

Remember to base64 the binary updates — binary frames are not routed.

Rate limiting yourself

Nothing throttles you, so high-frequency sources should throttle themselves. Pointer moves at 60 Hz across ten peers is a lot of redis traffic for a cursor.

js
let queued = null;
const sendThrottled = (msg) => {
  queued = msg;
};
setInterval(() => {
  if (queued) {
    send(JSON.stringify(queued));
    queued = null;
  }
}, 50); // 20 Hz is imperceptible for cursors and 3x cheaper

Coalescing — keeping only the newest value — is usually better than dropping or buffering, because for live state the latest value is the only one that matters.

One-way command channels

A producer that never wants to hear anything back can skip the websocket entirely and just POST. Remember that POST reaches everyone, including other producers.

sh
curl -X POST "$ROUTER/$CHANNEL" -d '{"type":"reload"}'

Released under the MIT License.