Syncing Yjs documents over WebSockets #

You have added Yjs to a collaborative editor, and the demo with y-websocket’s example server works. Now it has to run in production: behind your authentication, inside your existing WebSocket server, with rooms that load from and save to your database, and with clients that reconnect after a laptop sleeps without duplicating or losing edits. That requires understanding what actually crosses the socket. Yjs does the hard part — merging concurrent edits without conflicts — but the transport is deliberately left to you, and the difference between a toy relay and a production one is in how the sync handshake, update relay and document lifetimes are handled.

Root cause #

Yjs represents a shared document as a CRDT: every client holds a full replica, local edits produce small binary updates, and applying any set of updates in any order yields the same document. That property is what makes collaboration robust, and it shifts the transport’s job. The server does not need to order or transform edits; it needs to make sure every replica eventually receives every update, including updates made while it was disconnected.

A naive relay — broadcast each update to the other clients in the room — fails that requirement in two cases. A client that joins late receives only updates made after it joined and never sees the existing document. A client that reconnects after being offline missed updates in between, and its own offline edits never reached anyone. Both are solved by the sync protocol: on connect, each side sends a compact summary of what it has (a state vector), and the other side replies with exactly the updates the first is missing.

The Yjs two-step sync on connect On connect the client sends its state vector; the server replies with the updates the client lacks and sends its own state vector; the client replies with its offline edits; afterwards live updates are relayed to the room. The Yjs two-step sync on connect Client doc WS server Server doc sync step 1: my state vector compute diff for client sync step 2: updates you lack sync step 1: server state vector sync step 2: offline edits live updates, relayed to room The exchange is symmetric, so both late joiners and returning offline editors converge
State vectors make sync incremental: each side sends only what the other is missing.

Resolution #

The server below keeps one Y.Doc per room in memory while the room has connections. It speaks the standard y-protocols sync and awareness messages, so it works with the stock y-websocket client provider, and it lets you insert authentication, persistence and metrics where your application needs them.

import * as Y from 'yjs';
import * as syncProtocol from 'y-protocols/sync';
import * as awarenessProtocol from 'y-protocols/awareness';
import * as encoding from 'lib0/encoding';
import * as decoding from 'lib0/decoding';
import { WebSocket } from 'ws';

const MSG_SYNC = 0;
const MSG_AWARENESS = 1;
const UNLOAD_AFTER_MS = 30_000; // keep an empty room's doc briefly for quick rejoins

interface Room { doc: Y.Doc; awareness: awarenessProtocol.Awareness; conns: Set<WebSocket>; unloadTimer?: NodeJS.Timeout }
const rooms = new Map<string, Room>();

declare function loadDoc(name: string, doc: Y.Doc): Promise<void>; // apply stored state
declare function persistUpdate(name: string, update: Uint8Array): void; // append-only log

async function getRoom(name: string): Promise<Room> {
let room = rooms.get(name);
if (room) { clearTimeout(room.unloadTimer); return room; }
const doc = new Y.Doc();
await loadDoc(name, doc); // before any client syncs
room = { doc, awareness: new awarenessProtocol.Awareness(doc), conns: new Set() };
rooms.set(name, room);
// Every applied update — from any client — is persisted once and relayed to the room.
doc.on('update', (update: Uint8Array, origin: unknown) => {
persistUpdate(name, update);
const enc = encoding.createEncoder();
encoding.writeVarUint(enc, MSG_SYNC);
syncProtocol.writeUpdate(enc, update);
const frame = encoding.toUint8Array(enc);
for (const c of room!.conns) if (c !== origin && c.readyState === WebSocket.OPEN) c.send(frame);
});
room.awareness.on('update', ({ added, updated, removed }: any) => {
const changed = [...added, ...updated, ...removed];
const enc = encoding.createEncoder();
encoding.writeVarUint(enc, MSG_AWARENESS);
encoding.writeVarUint8Array(enc, awarenessProtocol.encodeAwarenessUpdate(room!.awareness, changed));
const frame = encoding.toUint8Array(enc);
for (const c of room!.conns) if (c.readyState === WebSocket.OPEN) c.send(frame);
});
return room;
}

export async function onYjsConnection(ws: WebSocket, roomName: string) {
ws.binaryType = 'arraybuffer';
const room = await getRoom(roomName); // auth already checked on upgrade
room.conns.add(ws);

ws.on('message', (data: ArrayBuffer) => {
const dec = decoding.createDecoder(new Uint8Array(data));
const enc = encoding.createEncoder();
const type = decoding.readVarUint(dec);
if (type === MSG_SYNC) {
encoding.writeVarUint(enc, MSG_SYNC);
// Handles step 1 (reply with diff), step 2 and plain updates (apply to doc).
// `ws` as transaction origin keeps the update from being echoed to its sender.
syncProtocol.readSyncMessage(dec, enc, room.doc, ws);
if (encoding.length(enc) > 1) ws.send(encoding.toUint8Array(enc));
} else if (type === MSG_AWARENESS) {
awarenessProtocol.applyAwarenessUpdate(room.awareness, decoding.readVarUint8Array(dec), ws);
}
});

// Start the handshake from the server side too: send our state vector.
const enc = encoding.createEncoder();
encoding.writeVarUint(enc, MSG_SYNC);
syncProtocol.writeSyncStep1(enc, room.doc);
ws.send(encoding.toUint8Array(enc));

ws.on('close', () => {
room.conns.delete(ws);
if (room.conns.size === 0) {
room.unloadTimer = setTimeout(() => { room.doc.destroy(); rooms.delete(roomName); }, UNLOAD_AFTER_MS);
}
});
}

On the client, the stock provider connects to your endpoint: new WebsocketProvider('wss://collab.example.com', 'doc-123', ydoc, { params: { ticket } }). It performs the client half of the handshake, sends local updates, reconnects with backoff and re-runs the sync on every reconnect, so offline edits flow back automatically. Authentication belongs on the upgrade, as in authenticating WebSockets with short-lived tickets; the Yjs protocol itself carries no identity.

Persistence can be as simple as appending every update to a log and replaying it on load, compacting periodically into a single snapshot — the approaches are compared in persisting CRDT updates on the server. Awareness — cursors, selections, presence — travels on the same socket but is ephemeral and never persisted, as covered in broadcasting cursor and awareness state.

A room's server-side lifetime A room starts unloaded, loads its stored updates on first join, becomes active while it has connections, drains with an unload timer when the last connection leaves, and returns to active if someone rejoins before the timer fires. A room's server-side lifetime Unloaded state only in storage Loading replay stored updates Active conns > 0, relaying Draining empty, unload timer first join doc ready last leaves rejoin Loading must finish before any client syncs, or late joiners receive an empty document
Load before sync, relay while active, unload lazily.

Edge cases #

Multiple server instances. Each instance holds its own Y.Doc for a room. If two clients of the same room connect to different instances, updates must travel between them: either route all connections for a room to one instance (consistent hashing on the room name) or relay updates between instances through Redis pub/sub, applying them to each instance’s doc. Because updates commute, relaying is safe in any order.

Large documents. The first sync of a large document sends it in one message. Raise maxPayload on the ws server accordingly, and consider compacting stored history so load time does not grow with every edit ever made.

Load race. If a client’s sync step 1 arrives before loadDoc finishes, the server replies from an empty document and the client’s copy looks correct only because the client already had it. The await getRoom before registering the connection prevents that.

Verification #

Open the same document in two browsers, disconnect one with DevTools’ offline mode, type in both, and reconnect: both must converge to the same text containing both sets of edits. On the server, compare document state across replicas by hashing the encoded state:

import { createHash } from 'node:crypto';
const fingerprint = (doc: Y.Doc) => createHash('sha256').update(Y.encodeStateAsUpdate(doc)).digest('hex').slice(0, 12);
// Log fingerprint(room.doc) on each client and on the server after activity settles; they must match
// (compare Y.encodeStateVector for a cheaper check that replicas have seen the same updates).

In production, track rooms loaded, connections per room, update rate per room and first-sync payload size. A growing first-sync size is the signal to compact storage.

What the sync handshake adds Broadcasting updates alone delivers neither existing history to late joiners nor offline edits on reconnect; the two-step sync delivers both completely. What the sync handshake adds relative to broadcasting updates only late joiner sees history % offline edits delivered % 0% 25% 50% 75% 100% Naive relay Two-step sync
Relaying live updates is half a protocol; the handshake is the other half.

Operational checklist #

FAQ #

Do I need y-websocket’s server, or can I use my own? #

You can use your own, as long as it speaks the same sync and awareness messages from y-protocols. That lets you integrate your authentication, storage and routing while keeping the stock client provider.

Does the server need to understand the document? #

It needs a Y.Doc per room to answer sync step 1 with the right diff and to persist a coherent state. A pure relay without a document cannot serve late joiners unless another client happens to be online.

How are conflicts resolved? #

Yjs resolves them deterministically inside the CRDT: concurrent insertions at the same position are ordered by client id, and every replica applies the same rule. The server does no conflict resolution at all, which is why CRDTs differ from operational transform.

Can I send Yjs updates as JSON? #

You can base64-encode them into JSON messages if your protocol requires text frames, at a size cost of about a third. Binary frames are simpler and smaller; see binary WebSocket frames with MessagePack for mixing binary and structured messages.

Back to Collaborative Editing with CRDTs.