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 is | When you disconnect | |
|---|---|---|
| Presence | cursors, who's here, what they're pointing at | it evaporates, nobody mourns it |
| Storage | the document | it 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:
{ , "ops": [{ "type": "set", , "key": "title", "value": "draft", }] }
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 A | Client B | Result | |
|---|---|---|---|
| A sets title to "draft" | clock 4 | ||
| B, having seen A's earlier ops, sets it to "final" | clock 7 | ||
| ops cross on the wire | receives 7 > 4, applies | receives 4, rejects | both screens: "final" |
| server echoes A's own op back to A | receives 4, not > 4, rejects | echo dedup for free |
Where it breaks
| What goes wrong | What happens | What you lose |
|---|---|---|
| Two clients write the same key at the same tick before seeing each other | each rejects the other's op and keeps its own; screens diverge | that key, until rejoin: the server's authoritative copy heals it |
| Two clients insert at position 2 at once | nothing; see below | nothing |
| Two people type inside the same word | a field-level winner would eat someone's sentence | handled 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.
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:
interface CursorData { x: number; y: number;+ cursorType?: "default" | "text" | "pointer"; }
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 devstarts 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.