Confession time: one of us (hi, it's me the writer) does a lot of Drawdy testing on a laptop that deserves retirement, and my PC starts to wheeze when a board gets big. The culprit that we found is the data storage and the free hand draw that we have on the board. A single pencil scribble is thousands of recorded points, and unlike a rectangle or a sticky note, there's no upper limit on how many points a stroke can carry. For that case, huge boards are almost always huge because of the points arrays, since it's the only thing in an element that can grow without bound.

Running my PC on starch.
So we went after the optimization problems. This post is about three optimizations that came out of that: representing a stroke's position with a six-number matrix instead of rewriting its points, deduplicating identical strokes with a content fingerprint so copy-paste costs almost nothing, and shrinking what the server actually stores. The end result, an edit that used to ship kilobytes now ships a few dozen bytes — which is the difference you can feel on a potato! (I love potato)
Six numbers instead of two thousand
Here's the funny part: we tried the matrix idea once before and rejected it. We thought doing so will add too much complexity for something simple point math could do. I mean we engineer could do that simple math to pick the right approach, but man are we bad at math.
Every edit on a shared board travels as an update to the shared document, and the document treats a plain array as a single opaque value. So if a stroke's position lives in its points array, then moving the stroke means rewriting the ENTIRE array, and rewriting the array means re-shipping all of it, yikes!. Imagine dragging a two-thousand-point scribble one pixel to the left and the update carries two thousand slightly-different points. Every frame-worth of drag. How does that sound? Pretty bad if I say so myself.
So the fix: new freehand strokes store their points once, normalized into a little local space, and carry a six-number affine matrix that places them in the world.
/**
* [a, b, c, d, e, f] -> | a c e |
* | b d f |
* | 0 0 1 |
*
* x' = a*x + c*y + e
* y' = b*x + d*y + f
*/
export type Affine2x3 = [number, number, number, number, number, number];
Now look at what "move the selection" costs in each world. The old path rewrites every point (and the stroke's outline points too); the new path touches two numbers:
if (DrawdyElementV2.isV2(el)) {
updates.push({
id,
changes: { matrix: Affine2x3.translate(el.matrix, dx, dy) },
});
continue;
}
const changes: Partial<Element> = {
points: el.points.map((p) => [p[0] + dx, p[1] + dy] as Point2D),
};
if (el.outlinePoints && el.outlinePoints.length > 0) {
changes.outlinePoints = translateOutlinePoints(el.outlinePoints, dx, dy);
}
Resize and rotate work the same way — they compose onto the matrix instead of touching geometry. Rotating a whole selection is one multiply per element, and the comment in the rotate tool earns its keep:
// v2: one compose does both halves of the job — orbiting the stroke
// around the selection centre and spinning it about its own.
const spin = Affine2x3.rotateAbout([cx, cy], deltaAngle);
for (const [elementId, mat] of state.initialMatrices) {
updates.push({
id: elementId,
changes: { matrix: Affine2x3.multiplyAffine(spin, mat) },
});
}
Rendering follows the same idea. We don't hand the matrix to the canvas one element at a time. Instead, we fold it into world coordinates once per matrix change, and we memoize the result. Move a stroke and only that stroke's cached points recompute. The recompute even reuses the same output array, so nothing new is allocated. Everything else on screen stays untouched.
The renderer plays along too. The board is drawn into 1024-pixel tiles, and a tile only re-rasterizes when something inside it changes. So a moved stroke dirties just the tiles it left and the tiles it entered. We also keep a cache of computed stroke outlines. When a matrix changes, the outline isn't regenerated at all — the cached one just gets re-mapped from the old matrix to the new one.
You can feel the size gap yourself. Drag the sliders — the first row is one move/resize/rotate, the second is copy-paste, which we'll get to next:
Bars are log-scale so the small ones stay visible. "After" also uploads the stroke's raw points to storage once — 12.5 KB here — but that happens one time per unique stroke, no matter how often it's edited or pasted. Sizes are close approximations of the real encoding, not exact wire captures.
The "before" bars grow with the stroke; the "after" bars don't care how detailed your scribble is. Neat!
Copy-paste without paying twice
The second trick is what happens to the points themselves, because they had to go somewhere. They don't live in the shared document anymore at all. When a stroke is committed, we run its points through a fast fingerprint function and get back a short key like 4096_a1b2c3... — the point count plus a 128-bit hash of the raw bytes. Then, the document keeps the key and the actual coordinates are uploaded once to plain blob storage under that key.
Content-addressing is what makes copy-paste nearly free. A pasted stroke is the same points with a different position — and since the position now lives in the matrix, the points of the copy are byte-identical to the original. Identical bytes, identical fingerprint. So when the paste goes to commit, this guard sees the key is already known and queues nothing:
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 }; // the document only ever sees the key
The storage side enforces the same rule. Uploads are write-once — the request itself says "only accept this if the key doesn't exist yet," and if it already does, the server answers with a rejection that we treat as success:
// 412 = the blob already exists (write-once keys) —
// someone else drew the same stroke first. Success.
if (!res.ok && res.status !== 412) {
throw new Error(`PUT ${res.status} for ${hash}`);
}
Downloads dedupe the same way: a board with fifty copies of one stroke asks for the blob once, and every copy on the receiving side shares the very same array in memory. And if you're wondering whether someone could guess another board's keys and fetch strokes they shouldn't — well, brute-forcing a key would take longer than the lifetime of the universe.
What the server keeps
The last piece is the path a board takes to disk, because your edits don't hit the database one by one — that would hammer it for no reason. Live edits are staged in Redis first: each board has a simple list of pending update buffers (already tiny, thanks to everything above — by this point a freehand stroke's entry carries a key and a matrix, never coordinates). Every ten seconds a background job folds a board's staged updates onto its stored copy and writes the result back as one binary blob:
const asUpdate = Y.encodeStateAsUpdate(latestState);
// Persist before trimming: if we crash after this and before the trim,
// the next cycle simply re-reads and re-writes (idempotent replace).
await canvasElementsRepository.replaceCanvasElements(canvasId, asUpdate, ...);
Only after the write succeeds does the job trim exactly the entries it consumed from the Redis list — anything that arrived mid-flush survives for the next cycle. Crash anywhere in between and the worst case is redoing work, never losing it.
There's a second job behind that one. A shared document's history accumulates invisible weight — records of things that were deleted, overwritten, moved a thousand times. When a room goes quiet, a compaction pass rebuilds the document from scratch, keeping only what's currently on the board:
const compactedDoc = new Y.Doc();
const compactedMap = compactedDoc.getMap("elements");
for (const [k, v] of rawMap) {
compactedMap.set(k, v);
}
const compactedBlob = Y.encodeStateAsUpdate(compactedDoc);
Every run logs its before-and-after byte counts, and each stored board carries its own size on the record — with a hard 16 MiB guard so no single board can eat the database. With all these changes, the storage goes down from 10 MB down to 10 kB, I mean holy moly! I even got a "(wtf)" comment from my senior too, which remains the most honest code review comment I've ever got by far.
With all these changes, the potato is happy now! Dragging a dense scribble used to mean re-shipping and re-storing it over and over; now the scribble is written once, fingerprinted once, and everything after that is six numbers. Most of performance work turned out to be exactly this — not making the computer faster, just politely declining to repeat ourselves.