I Tried to Rebuild Liveblocks

The assignment was a collaborative whiteboard: multiplayer cursors, shared shapes, seven days. The sane move was Liveblocks, drop in their hooks, ship by Wednesday.

I didn't take it, mostly because I'd never built the part underneath and it bugged me that I didn't know if I could. My pre-week notes rejected the vendors in writing: sync logic "abstracted away entirely", no control over conflict resolution, a dollar per monthly user forever. The same notes promised conflict resolution would be "last-write-wins with timestamps". The timestamps didn't survive.

February 18

Between 7:58pm and 10:11pm the whiteboard's sync guts came out and became packages: types, storage engine, server, client, React hooks. By morning the whiteboard was the first customer of the project instead of the project.

Everything rides one WebSocket per room, speaking JSON. Two animals share it:

What it isWhen you disconnect
Presencecursors, who's here, what they're pointing atit evaporates, nobody mourns it
Storagethe documentit has to survive and converge

Joining is a short handshake: the server sends who's here, then a snapshot of the document. If the room is empty your client seeds it; two clients once did that in the same moment, so there's a first-writer-wins guard with a commit to its name. One edit on the wire:

Fig. 1One edit, on the wire
{
  ,
  "ops": [{
    "type": "set",
    ,
    "key": "title",
    "value": "draft",
    
  }]
}
path: Walks containers from the root; [] means the root object itself.
clock: The Lamport clock, the only field that decides anything. Everything else is addressing.
storage:ops: The server rebroadcasts this to every client, sender included. Your echoed copy carries your own clock, fails the clock check, and is ignored.
A storage op, whole. Plain JSON on a plain WebSocket. Hover or tap a field.

Almost every field is addressing. The one doing work is clock.

The clock

The entire conflict-resolution engine, from packages/storage/src/clock.ts:

export class LamportClock {
private _value = 0;
tick(): number { return ++this._value; }
merge(remote: number): void { this._value = Math.max(this._value, remote) + 1; }
get value(): number { return this._value; }
}

Every local write ticks the clock and stamps the op. Every received op merges: jump to the max, plus one. So the number isn't time; it's roughly how much history this client had seen when it wrote, and whoever has seen more wins.

The receive rule is one comparison: apply a remote op only if its clock is strictly greater than what you have. Worked through:

Client AClient BResult
A sets title to "draft"clock 4
B, having seen A's earlier ops, sets it to "final"clock 7
ops cross on the wirereceives 7 > 4, appliesreceives 4, rejectsboth screens: "final"
server echoes A's own op back to Areceives 4, not > 4, rejectsecho dedup for free

Where it breaks

What goes wrongWhat happensWhat you lose
Two clients write the same key at the same tick before seeing each othereach rejects the other's op and keeps its own; screens divergethat key, until rejoin: the server's authoritative copy heals it
Two clients insert at position 2 at oncenothing; see belownothing
Two people type inside the same worda field-level winner would eat someone's sentencehandled by Yjs, not by me

The textbook fix for the first row is breaking ties with an actor ID. Mine is the server's copy. Noted for v2.

Lists

Maps only need a winner. Lists have two people inserting at the same index. The trick is to stop using indexes: every item gets a position string, ordered alphabetically, and inserting between two items means minting a string between theirs. Between "V" and "g" there is always another string.

Fig. 2Room between the letters
"V""g"
Toggle the inserts. Sky is client A, purple is client B. Alphabetical order is the list order, so nothing ever renumbers.

Two clients inserting "at the same spot" are inserting between different neighbors, so both land and there's no tie to break. Figma does the same thing; Evan Wallace's 2019 write-up calls it fractional indexing, and their reasoning for skipping CRDTs (a central server exists, so use it) is the one I'd backed into.

Text

Rich text is a different sport. At 3:16am on February 21, four commits landed inside one minute: a Yjs sync protocol on the server, a provider package, a Tiptap binding, a collaborative editor example. That minute is the boundary: my JSON ops for shapes and fields, Yjs for text, same socket, same persistence.

The examples pushed back

I kept building apps against the SDK to find where it was secretly whiteboard-shaped: a todo list, a Notion-style editor, a markdown editor, a workflow builder. A cursor born on a canvas has no concept of a text caret. The entire fix:

Fig. 3A canvas cursor meets a text caret
 interface CursorData {   x: number;   y: number;+  cursorType?: "default" | "text" | "pointer"; }
Commit a8cc057, February 23, 1:51am. An hour later a second commit added the text-selection highlight box.

Skipped: the mutation API that went from 172 lines to 75, the night one message triggered 900 subscriber calls, why undo clobbers other people's edits on purpose. Reconnection, persistence, and the Yjs bridge internals too.

Totals

  • 297 commits, February 16 to 25
  • 10 packages, 6 example apps
  • $410.36 of Claude Code
  • github.com/ryanwaits/lively, lively dev starts the server on port 1999

Liveblocks open-sourced their sync engine on February 18, the same day as the extraction night. If you have a week and a deadline that only needs a whiteboard, use them. I wouldn't trade the week.