In part one we walked through how a Drawdy session talks — the shared Yjs document, the message types, the Redis backbone that keeps several servers in step. All of that is correctness: making sure everyone ends up with the same board.
This part is about speed. In a multiplayer app, the lag you feel isn't only the distance to the server — it's how much you're cramming down the wire and how often. A cursor that updates twenty times a second. A stroke with two thousand points. A name and avatar tagging along on every little change. It adds up fast, and it's the difference between a session that feels live and one that feels like it's dragging. So a lot of our collaboration work isn't about sending things faster. It's about sending less.
Cursors that don't shout
Cursors are the noisiest thing in a session. Move your mouse and, if we sent every event the browser hands us, that's a message per pixel — hundreds a second, times everyone in the room. Nobody's eyes can use that many updates, so we don't send them.
Every cursor position goes through a throttle. We keep only the latest position and send at most one update every 50 milliseconds — about twenty a second, which is already smoother than the eye tracks. If you're whipping the mouse around, all the intermediate positions in that window are simply dropped; only the last one survives. The one exception is your cursor leaving — that we send immediately, so your pointer never lingers as a ghost on someone else's screen after you've gone.
private static CURSOR_THROTTLE_MS = 50; // ~20 updates/sec
private _flushCursor() {
this._lastCursorSend = performance.now();
if (this._pendingCursor) {
this.awareness.setLocalStateField("cursor", {
x: Math.round(this._pendingCursor.x * 10) / 10,
y: Math.round(this._pendingCursor.y * 10) / 10,
});
}
}
Notice the rounding on the way out. A cursor doesn't need sixteen digits of precision. A tenth of a pixel is already far finer than anyone can see, so we snap the coordinates to one decimal place before sending. It shaves a few bytes off every single cursor message, and there are a lot of cursor messages. The same throttle-and-round treatment goes for live drawing previews and for the viewport we broadcast in follow-mode.
You can feel the gap between "what the browser fires" and "what we send" yourself:
Gray dots are raw pointer events; lime dots are the messages that actually go out.
The bigger win, though, is what we don't attach to those messages. Yjs has a built-in presence system called awareness, and it has one expensive habit: whenever any part of your local state changes, it rebroadcasts all of it. If we stored your name, color, and avatar in there, every one of those twenty-per-second cursor updates would drag your entire profile along with it. So we don't. Identity travels on its own separate message, sent once when you join and again only if you reconnect or rename:
// Send user info via custom message (fire-and-forget) — keeps it
// out of the awareness state so cursor/selection updates are smaller.
What's left in the high-frequency channel is just the stuff that actually changes moment to moment: where your cursor is, what you've got selected, what you're mid-drawing.
The strokes we never send
Cursors are frequent but small. Freehand strokes are the opposite problem: rare, but huge. A single scribble with the pencil can be thousands of points, and each point is a pair of full-precision numbers. Drop a few of those on a board and they dominate the document. And because the document is what every edit syncs against, a handful of fat strokes makes every change more expensive to send.
Our answer is a little unusual: the points never enter the shared document at all. Just before a freehand element is committed, we pull its point list out and replace it with a short hash string — a fingerprint computed from the points themselves. The raw numbers get uploaded separately to blob storage, which is plain file storage, the same kind of place image uploads live. What travels in the live document is a forty-character string standing in for what might have been fifty kilobytes of coordinates. Fifty kilobytes down to forty characters. Not a bad trade.
private _stripPoints(el: Record<string, unknown>): Record<string, unknown> {
if (el.type !== "freedraw" || !Array.isArray(el.points)) return el;
const points = el.points as PointsTuple;
const floats = this._elementCodec.encodePoints(points);
const hash = this._elementCodec.hashPoints(floats);
if (!this._pointsCache.has(hash)) {
this._pointsCache.set(hash, points.map((p) => [p[0], p[1]]));
this._pendingUploads.set(hash, floats);
this._schedulePointsUpload();
}
return { ...el, points: hash }; // element now carries a short string
}
The raw points themselves are packed as tightly as we can manage: a flat array of 64-bit floats, sixteen bytes a point, no JSON brackets or commas. That binary blob is what gets uploaded:
public encodePoints(points: PointsTuple): Float64Array {
const floats = new Float64Array(points.length * 2);
for (let i = 0; i < points.length; i++) {
floats[i * 2] = points[i][0];
floats[i * 2 + 1] = points[i][1];
}
return floats;
}

The heavy part goes to storage once; the document only ever carries the fingerprint.
Because the key is a hash of the content, identical strokes collapse into one. Two people trace the same shape? Identical bytes, identical hash, stored exactly once. The storage layer even treats a "this key already exists" response as success rather than an error. On the receiving end, a client that sees an element pointing at a hash it doesn't recognize downloads the blob, swaps the real points back in, and renders the stroke.
A hash that has to be instant
That hash key looks like a detail, but there's a real constraint hiding in it. The moment we swap a stroke's points for a hash happens inside a Yjs transaction — a block of work that has to run start to finish without stopping. And the obvious tool for the job, the browser's built-in crypto, doesn't work that way: you ask it for a hash and it gives you the answer later. Inside a transaction, there is no later. So the standard, sturdy option was off the table.
Instead we use cyrb128, a tiny hash that runs in one uninterrupted pass. It walks the raw bytes, stirring each one into four running numbers, and spits out a short fingerprint as hex:
for (let i = 0; i < bytes.length; i++) {
const k = bytes[i];
h1 = h2 ^ Math.imul(h1 ^ k, 597399067);
h2 = h3 ^ Math.imul(h2 ^ k, 2869860233);
h3 = h4 ^ Math.imul(h3 ^ k, 951274213);
h4 = h1 ^ Math.imul(h4 ^ k, 2716044179);
}
A quick, informal hash like this can't completely promise that two different strokes never share a fingerprint. So we narrow the risk by prefixing the key with the point count. Two strokes of different lengths can now never collide, no matter what the hash does. Cheap belt-and-suspenders on top of an already astronomically unlikely event.
public hashPoints(floats: Float64Array): string {
return `${floats.length}_${hashPointsBytes(floats)}`;
}
Sending only what changed
The last layer is discipline about the document itself. Yjs is good at sending just-what-changed, but only if you let it. To Yjs, a value it can't see inside is all-or-nothing. Store a thousand shapes as one big list and changing a single one forces you to re-send the ENTIRE list. Give each shape its own entry, and the same edit produces an update that touches only the one that moved. We keep benchmark scripts around whose whole job is to prove that gap to ourselves — this is exactly the kind of thing that's easy to get wrong by accident.
We lean on that in a few places. Rapid edits — dragging, resizing, drawing — are batched behind a 200-millisecond window and flushed together in one transaction. A two-second drag becomes a handful of updates rather than a hundred. And when we need to reconcile the whole board against a new set of elements, we don't clear it and rebuild, which would generate a delete and an insert for every shape. We diff by id and touch only what genuinely changed.
Put the two parts together and the picture is simple, even if the machinery isn't. A shared document keeps everyone correct. A stack of small decisions about what to send, how often, and in what form keeps everyone fast. The strokes stay out of the document, the cursors stay quiet, the identity stays on its own channel. What's left on the wire is small enough that a board on the other side of the world feels like it's right next to you.