Nobody owns the canvas
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.
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.
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
the other tab · usersMoves
sorted by server timestamp · what replays
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.