kuluru vineeth

Nobody owns the canvas

July 30, 2026 · updated August 19, 2026 · growing · certain · 9 min

Two people are drawing on the same board in the same second, and their strokes cross. Where the two lines overlap, one of them has to sit on top of the other, and both browsers have to agree which. Neither person can settle it. At the instant each hand moves, that browser has not yet seen the other’s stroke, so there is no shared moment either of them can point to and call the tiebreak. This is the whole problem of realtime collaboration compressed into one image: two hands, one canvas, and no common now.

The heavyweight answers to this are famous. Conflict-free replicated data types give every edit a partial order and a merge function that provably converges. Operational transformation rewrites each incoming operation against the ones that raced it, so that applying them in different orders still lands in the same place. Both are real engineering, and both start from the same premise: there is a shared document, and the difficulty is reconciling concurrent edits to it. Digiboard drops that premise. There is no shared document. There is nothing to merge, because nothing is ever edited. What follows is how a whiteboard that supports a dozen people on one 4000 by 2000 canvas gets away with resolving conflicts using a library sort function, and why that is not the shortcut it first looks like.

A stroke is a fact, not a command

Every mark on the board is a Move: an immutable record carrying a uuid, the points of the stroke, and its options, the color, the width, the shape. It is created once and never touched again. There is no canvas object that strokes mutate, no document state that accumulates edits over time. The board you are looking at is not stored anywhere as a board. It is a list of Moves and a function that draws them.

The detail that gives the whole design away is erase. You would expect erasing to remove something. It does not. An erase is another Move, a rectangle filled with the background color and appended to the same history as everything else. Deleting is adding. The history only ever grows, and the pixels that look gone are simply covered by a later fact.

append-only historydraw · pendraw · penerase · rectstill just a movedraw · linedraw · penthe canvas: replay in order

The board is a list of moves replayed in order. Erase is not an operation on the canvas, it is one more move that paints over the earlier ones.

Because a stroke is a fact rather than a command, undo needs no inverse. There is no “un-draw” operation to compute, no diff to roll back. To undo, a client drops its most recent fact and rebuilds the picture from the shorter list. The past is immutable, so going back is only ever replaying less of it.

The clock is the referee

If strokes are facts and history is append-only, something still has to decide the order of two facts that happened at nearly the same time. Digiboard hands that job to a single wall clock, and it does so in a way that surprised me the first time I traced it. When a browser draws, it does not add the stroke to its own canvas. It emits a draw event to the server and waits. The server does one thing that matters:

const timestamp = Date.now();

It stamps the incoming move with its own clock at the moment of receipt, gives the move a fresh uuid, stores it in that user’s bucket, and fans it out to the room, including a your_move echo sent straight back to the author. Only when that echo returns does the drawing browser commit its own stroke. You do not trust your own hand. Your stroke is provisional until the one server has told you what time it happened.

your browsera new stroke: ghost onlyemit('draw')one serverDate.now()17:02:41.207your_moveyour canvascommitted after the echo

Even your own stroke round-trips. The author waits for the server’s timestamp before the ink appears, so no client privileges its own input.

This is a deliberate refusal of a common shortcut. Most collaborative tools paint your own input immediately, an optimistic local echo, and reconcile with the server afterward, because the round trip is latency a user can feel. Digiboard declines. By making even the author wait for the stamp, it guarantees that every participant, the author included, orders every stroke by the same integer from the same clock. There is exactly one place in the system where time is assigned, and it is pointedly not where the pen is.

One thing is deliberately left out of this machinery: cursors. The live position of everyone’s pointer is broadcast too, throttled to one update every 150 milliseconds, but it is never stamped, never stored, and never sorted. A cursor is presence, not a fact about the picture, so it is allowed to be lossy and immediate. Only the things that belong in the history pay the cost of the round trip.

Everyone sorts, and the picture agrees

Each browser keeps the moves it knows about in three buckets: your own moves, a map of other users’ moves keyed by socket, and moves that arrived before their author was known. On every change, the client does not try to patch the canvas in place. It throws all three buckets into one array, sorts, and replays from scratch:

const moves = [...movesWithoutUser, ...myMoves];
usersMoves.forEach((userMoves) => moves.push(...userMoves));
moves.sort((a, b) => a.timestamp - b.timestamp);

That sort is the entire conflict resolution strategy. Not a merge, not a transform, not a vector clock. Array.prototype.sort over a server-assigned integer, recomputed by every client on every update. Because the timestamps come from one clock and the comparison is deterministic, any two browsers holding the same set of moves produce the same array, replay it stroke by stroke onto their canvas in the same order, and therefore paint the identical picture, down to which line sits on top where two of them cross. Agreement is never communicated between clients. It is recomputed, independently and identically, by each of them.

Two tabs draw the same board. Inject a stroke that arrives now but carries an older server timestamp, and watch it sort into the middle.

you · your bucket

17:02:41.12017:02:41.240

the other tab · usersMoves

17:02:41.15517:02:41.300

sorted by server timestamp · what replays

17:02:41.12017:02:41.15517:02:41.24017:02:41.300

the canvas · 4 of 4 strokes

Two tabs, one clock. Every stroke is placed by its server timestamp.

The load-bearing figure. Two tabs feed one sorted list, and injecting a late-timestamped arrival shows both the convergence and the seam discussed below.

This is why the design can be so relaxed about the network. It does not matter what order moves arrive in, or whether a browser catches one live or fetches it on join, or that the same move sits in a different bucket on a different client. The picture is a pure function of the set of moves and their timestamps. Reordering, a duplicate across buckets, a straggler that shows up late: none of it changes the output, because the output is rebuilt by sorting, every single time.

It is worth naming what this is not, because the shape runs against instinct in two directions at once. It is not a CRDT or an OT system: there is no shared structure being merged, only independent recomputations that happen to land in the same place. And it is close to the opposite of a durable, sequenced event log. A log assigns a contiguous per-stream sequence number, treats any gap in that sequence as an error to detect and repair, and persists everything so a late reader can be brought back to the exact truth. Digiboard uses a wall clock instead of a sequence, tolerates any arrival order rather than repairing gaps, and keeps nothing at all. Where a log says here is the authoritative order, replay it carefully, Digiboard says here are the facts, sort them yourself. Both converge. They disagree about who does the work, and whether any of it survives a restart.

Undo pops, and redo forgets when it happened

Undo and redo sit right on top of this design and expose its seams honestly. Because the server keeps each user’s moves in their own bucket, undo is a pop: the client emits undo, the server removes that user’s most recent move from their bucket, tells the room, and every browser re-sorts and repaints without it. It is per-user by construction, with no coordination and no notion of a global undo stack.

Redo is where the wall clock bites. Redo takes the move you undid and re-emits it as a fresh draw. The server stamps it with Date.now() again, a new and therefore later timestamp. So a redone stroke does not slot back where it was in the picture. It re-enters at the current instant and sorts to the top of the z-order. Redo does not mean put it back. It means draw it again, now. In a tool where depth is decided entirely by timestamp, that is a real change of meaning, and it falls directly out of a stroke having no identity for its original place in the stack beyond the clock reading it once received.

Scar tissue

The redraw path is where the cracks show, and the first one is a genuine bug you can trigger in the figure above. Full replay of a large board is expensive, so there is an optimization: if the sorted array grew by exactly one since the last render, skip the full replay and just draw the single last element. The problem is that “grew by one” is measured in arrival order, while “the last element” is measured in timestamp order, and those are not the same axis. When a move arrives out of order, an older timestamp than moves already on the canvas, it sorts into the middle of the array. The array did grow by one, so the fast path fires, but the element it paints is the last by timestamp, not the move that actually just arrived. The genuinely new stroke sits unpainted in the middle of the list until the next full replay sweeps it in. The bug hides because full replays happen often enough that the gap heals itself within a frame or two, which is exactly what makes it the kind of bug that survives.

The second scar is that none of this is persisted. Rooms are plain in-memory Maps on the server, keyed by a four-character room id, and a restart erases every board in existence. There is no database, no snapshot, no disk. Worse, the identity that ties your strokes to you is your socket id, and a socket id does not survive a reconnect. Drop your connection and come back, and you are a new user to the server with an empty bucket. Your earlier strokes are still on everyone’s canvas, because they are just facts in the shared history, but they are no longer yours: you cannot undo them, because the bucket they lived in belonged to a socket that no longer exists. The board remembers your work and forgets that it was you who made it.

The third scar is the assumption holding up the entire scheme: there is one clock. Date.now() is authoritative only because every move passes through the same process to be stamped. Put two server instances behind a load balancer and moves start getting their timestamps from two clocks that drift against each other by milliseconds, which is precisely the resolution at which two people drawing at once get ordered. Total order quietly stops being total, and two browsers can disagree about which line is on top. The room cap of twelve users is what keeps this theoretical: one process holds a room comfortably, so the single clock is never actually contended. This is a single-server system by assumption, not by accident, and inside that box it is exactly as correct as it needs to be.

What the sort was really buying

What stays with me is how much complexity this design refuses to carry, and it buys that refusal by relocating one decision. It never merges, because it never lets two versions of the picture exist to be merged. It never repairs, because it never trusts an incremental update enough to need repair. It moves the single irreducible question, in what order did these strokes happen, to one clock in one place, and then lets every browser answer every other question by sorting and redrawing from nothing. The strokes are immutable, the history only grows, and the canvas is always a fresh computation rather than a thing being kept in sync.

Let one clock decide the order, and no client has to own the picture.