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:

[ the socket ]
presencestorage
  • whatcursorsthe document
  • disconnectevaporatesmust survive

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:

[ one edit ]
type
storage:ops
path
[] (the root object)
key
title
value
draft
clock
4

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

[ breaks ]
whatresult
same key, same tick
two writes, unseenscreens diverge until rejoin
lists
insert at position 2nothing; see below
text
type in the same wordYjs, not 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.

[ list order ]
V
existing
a
client A
d
client B
g
existing

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:

[ a8cc057 ]
  • xnumber
  • ynumber
  • cursorTypetext?

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

[ the week ]
  • 297

    commits

    Feb 16 to 25

  • 10

    packages

    6 example apps

  • $410

    Claude Code

    port 1999

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.