Open a Drawdy board with someone else and it feels like you're both leaning over the same sheet of paper. You drag a shape, it moves on their screen a heartbeat later. Their cursor drifts across yours. Someone starts sketching and you watch the line appear stroke by stroke. There's no "save," no refresh, no sense that anything is being sent anywhere — it just stays in sync.
Magic? No — plumbing. The foundation is fairly boring: a shared document that every participant holds a copy of, plus a steady trickle of small messages that keep those copies agreeing. We use Yjs for the document part. It's a well-worn library for exactly this — a shared document that merges everyone's edits without fighting about it. The messages ride a WebSocket, which is just a connection that opens once and stays open, so data can flow both ways without anyone knocking first. What we built is the plumbing around those two: a server that catches new arrivals up, a handful of message types for everything Yjs doesn't cover, and a way to keep it all working when there's more than one server behind the scenes.
This is part one of two. Here we're looking at how a session communicates. Part two is about how we keep the messages small so it all feels instant.
Catching a new arrival up
The interesting moment in any collaborative session is the first one: someone joins a board that already has content and other people on it, and within a blink they see everything. Getting that right is the handshake.
When you connect, the server speaks first. It sends what Yjs calls a sync step 1 — essentially "here's a summary of everything I already have, tell me what I'm missing." That summary is a compact fingerprint of the document (the docs call it a state vector), not the document itself:
const encoder = encoding.createEncoder();
encoding.writeVarUint(encoder, ProtocolMessageType.messageSync);
syncProtocol.writeSyncStep1(encoder, room.doc);
client.send(encoding.toUint8Array(encoder));
Your side compares that fingerprint against its own (empty, if you just joined) and replies with a sync step 2: only the pieces the other side is missing. After that one exchange, both sides hold the same document. Nobody ever sends the whole thing again. Every edit — a moved shape, a new sticky, a deleted arrow — travels as a tiny note describing just that one change (a delta, if you like the term). The server passes it along to everyone else in the room exactly as it arrived.

One exchange to catch up, then it's small notes back and forth for the rest of the session.
One subtlety makes the whole thing safe: every update carries an origin tag saying where it came from. An edit from your own browser tab is "local", one that arrived over the socket is "ws-client", and one relayed from another server is "pubsub". Those tags are how we make sure an update flows outward exactly once and never loops back on itself — they matter a lot in the multi-server story below.
One socket, many kinds of message
Yjs only cares about the document. But a session is more than the document — it's also cursors, names and colors, the little emoji someone throws, where a person is looking when you follow their view. All of that shares the same single WebSocket, so we need a way to tell the messages apart.
The first byte of every message is a type code. Yjs claims a couple of them; we define the rest for the things it doesn't handle:
| Code | Message | What it carries |
|---|---|---|
| 0 | sync | Document deltas (the handshake and every edit after) |
| 1 | awareness | Cursors, selection, live drawing previews |
| 100 | permission | "You're now an editor / viewer" |
| 104 | emote | A one-off emoji reaction |
| 105 | viewport | Where someone is looking, for follow-mode |
| 106 | user info | Name, color, avatar |
| 107 | reload | "Drop your copy and re-sync from scratch" |
| 200 | presence resync | A server-to-server request, never seen by a browser |
Our own messages use a dead-simple frame: one byte for the type, then the payload as JSON. The client attaches its own id and sends it off without waiting for a reply. Presence is fire-and-forget by nature — a cursor position that's a moment stale is worthless anyway:
public sendCustomMessage(type: number, payload: Record<string, unknown>) {
const ws = this._provider?.ws;
if (!ws || ws.readyState !== WebSocket.OPEN) return;
payload = { ...payload, clientId: this._provider.awareness.clientID };
const jsonBytes = new TextEncoder().encode(JSON.stringify(payload));
const msg = new Uint8Array(1 + jsonBytes.length);
msg[0] = type;
msg.set(jsonBytes, 1);
ws.send(msg);
}
Splitting things across message types isn't just tidiness — it's the first of our bandwidth decisions. A person's name and avatar barely ever change. Their cursor moves twenty times a second. So identity gets its own rarely-sent message (type 106) instead of riding along in the cursor stream. That's what stops every mouse twitch from re-sending someone's whole profile, and it's the heart of part two.
When one server isn't enough
Everything above works great with one server. But a busy app runs several, and your connection only plugs you into one of them. Two people on the same board can easily land on two different servers — and a server only knows about the people plugged directly into it. Left alone, they'd be editing the same board in two separate bubbles, each blind to the other.
So the servers gossip. Every message a server receives, it also republishes onto a shared Redis channel — think of it as a group chat that every server is in. To keep that cheap we don't wrap anything in JSON; each relay is a tight little binary envelope: which server sent it, which room it's for, then the raw update bytes.
// [1 byte instanceId length][instanceId][1 byte room length][room][update bytes]
payload[offset++] = instanceBuf.length;
instanceBuf.copy(payload, offset); offset += instanceBuf.length;
payload[offset++] = roomBuf.length;
roomBuf.copy(payload, offset); offset += roomBuf.length;
Buffer.from(data).copy(payload, offset);
pub.publish(config.pubsubChannel, payload);
The obvious danger with a scheme like this is an echo. A server publishes an update, hears it come back on the channel, republishes it… and the room melts down in a feedback loop. Yikes. Two things prevent that. First, the sender stamps its own id on every message and ignores anything that comes back with its own stamp. Second, a relayed edit is applied to the local document with that "pubsub" origin from earlier. That origin means "already from elsewhere — forward it to my own clients, but don't publish it again." The update reaches everyone, exactly once.

Every server hears every message — the stamp is what keeps a server from replaying its own.
There's one last gap to close. When a fresh server spins up a room, it knows the document — it can load that from storage — but it has no idea who's already connected over on the other servers. So the moment its first person joins, it asks over that same channel: "anyone else hosting this room? Introduce your people." The other servers answer by re-sending their users' cursors and identities, and the newcomer's roster fills in. It's the one piece of the protocol a browser never sees.
That's the shape of a Drawdy session: one shared document kept in step by Yjs, a small set of message types for the human stuff around it, and a Redis backbone so it all holds together across servers. It works because most of the traffic is tiny. Part two is about how we keep it that way — the strokes we never send, the hash that has to be instant, and the cursors that have learned not to shout.