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.
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.
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.
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.
Related #
- CRDT vs Operational Transform for Real-Time Editing — why Yjs needs no server-side transform.
- Broadcasting Cursor and Awareness State — the ephemeral half of the protocol.
- Persisting CRDT Updates on the Server — storage behind
loadDoc. - Scaling WebSocket Broadcast with Redis Pub/Sub — relaying updates between instances.
Back to Collaborative Editing with CRDTs.