Back to blog
Collaboration, Part 1: Session Talks
Engineering

Collaboration, Part 1: Session Talks

Aug 6, 2026·Drawdy Team·7 min read

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 (if you just joined, the copy of the board it downloaded just before connecting) 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 trims it down to what the board doesn't already have and passes that along to everyone else in the room.

The join handshake: the server sends a fingerprint of what it has, the client answers with only what's missing, and after that only tiny edits travel in either direction

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 the server reads back from the board's update stream is "stream", and a cursor update relayed from another server is "pubsub". Those tags mark the separate paths that 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

Most of 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 edit a server receives, it appends to that board's own Redis stream, which every server hosting the board reads from. Everything else, it 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 message bytes.

// [1 byte instanceId length][instanceId][1 byte room length][room][message 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 cursor update is applied with that "pubsub" origin from earlier, on a path that means "already from elsewhere — forward it to my own clients, but don't publish it again." The update reaches everyone, exactly once.

Two servers joined by a Redis channel, with a user on each; an edit stamped with its home server's id crosses over, and the sender ignores its own echo

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.

The canvas is open

Start your first board together

Connect your AI, bring your team, or just start drawing. The canvas opens with no signup.

Start drawing now