A real-time collaborative text editor built from scratch. Several people type into the same document at the same time, over a network that delays, reorders and drops them offline, and every copy converges on the same text, with no locking, no operational transform server, and no "who typed first" arbitration.
Type into three editors at once in your browser → Three replicas on one page exchange the same JSON operations the Go relay forwards, over a simulated network with adjustable latency and jitter and a per-replica offline switch. A status bar shows when they have converged, and a table shows every character's position.
Two users type into the same position at the same instant. A REST API resolves this by making one of them lose: last write wins, and a keystroke vanishes. Locking resolves it by making one of them wait, which is unusable at typing latency.
A CRDT resolves it by making the question meaningless: if every operation commutes, the order they arrive in does not matter, and every replica that has seen the same set of operations holds the same document. No server arbitration, and no round trip before your own character appears.
Every character gets a position rather than an array index, and a new character's
position is generated strictly between its neighbours' (frontend/src/position.ts).
Nobody's insert renumbers anybody else's character, so concurrent inserts never invalidate
each other: a merge is a sorted insert, not a transformation of every operation that came
before.
Positions are digit strings compared lexicographically. The first version used a float
and took the midpoint, and repeatedly typing into the same gap halves it every keystroke:
a float64 runs out of bits after about 50 of those and two characters collide. A string
has no floor; when there is no digit left between two positions it just grows one digit
longer. positionBetween never returns a string ending in 0, because nothing sorts
strictly between "k" and "k0", and allowing one would eventually create a gap that
cannot be split.
Characters sort on (position, siteId). Two users typing into the same gap at once can
still generate the same position, and without the site tie-break each replica would order
them by arrival and diverge. Each site also appends a short tag derived from its id to the
positions it generates, so in practice positions from different sites do not collide at
all.
Generation is deterministic, which caused the one bug the convergence test found: delete a character and type into the same gap, and the new character got the deleted one's exact key back. The other replicas hold that key as a tombstone and dropped the new character, so the typist saw it and nobody else did. A site now steps past any key it has ever used.
Splitting the gap is not enough on its own. Two people typing into the same spot at once
each get positions that start with the same digit and differ only in the site tag at the
end, so their letters sort alternately. On the demo page, one person adding "- eggs" while
another turned "coffee" into "coffee beans" converged on beeggasns: every replica agreed,
which is all convergence promises, and nobody had typed it.
So a character typed straight after the same site's previous character does not split the gap. It takes that character's position and bumps the last digit (or adds one digit when the last is already the largest). Every character of a run then shares the run's prefix, and a concurrent run from another site, which carries a different prefix, sorts as a block before or after it. The same rule keeps positions short while typing normally: 2000 characters typed in a row stay under 200 digits.
A delete records the character's key in a tombstone set before removing it. The network may deliver a delete before the insert it targets; the insert then arrives, finds its key tombstoned, and stays deleted. Both operations are idempotent, so replaying anything after a reconnect is safe.
diffToOperations compares the text box before and after a change, finds the one
replaced range, and emits deletes and inserts for it. That covers typing anywhere,
backspace, cut and paste. Remote edits keep the local cursor anchored to the character it
sat after rather than to a numeric offset.
The server forwards operations byte for byte and never parses them; merging is entirely the clients' job. One hub goroutine owns the client set and all mutation goes through channels, so there is no mutex on the client map. Each connection gets a read goroutine and a write goroutine with a buffered send channel, so one slow client cannot block the broadcast; if its buffer fills it is dropped rather than stalling everyone. Broadcasts skip the sender, who has already applied its own edit.
cd frontend && npm test # CRDT
cd backend && go test ./... # relayThe CRDT suite includes a randomized convergence test: three replicas make 120 random
concurrent inserts and deletes each round, operations are delivered in random order and
sometimes twice, and after everything arrives all three documents must match, across 60
seeds. It also checks that positionBetween stays strictly between its bounds, keeps
splitting one gap 2000 times where a float failed after about 50, that a delete which
arrives before its insert still wins, and that concurrent words typed into the same spot
come out whole. Those last tests fail if run allocation is switched off. The relay test connects two WebSocket clients and
checks a message reaches the other client unchanged and is not echoed to its sender.
Requires Go 1.22+ and Node.js.
cd backend && go run . # relay on ws://localhost:8080/ws
cd frontend && npm install && npm run devOpen http://localhost:5173/ for the three-replica demo, or
http://localhost:5173/?ws=ws://localhost:8080/ws in several windows to edit through the
relay. GitHub Actions runs both test suites, builds the frontend, and publishes the demo to
Pages on every push to main.
- Runs stay whole only when typed forward. The run rule applies to a character typed after your own previous one. Two people inserting characters in front of their own cursors at the same spot at the same moment (typing backwards) can still interleave. Sequence CRDTs such as RGA and Fugue prevent that case too.
- Tombstones are never collected. The set of deleted keys only grows.
- Positions grow with edit history. Heavy editing in one spot lengthens the position strings there.
- No persistence or late join. Documents live only in each client's memory, and the relay stores nothing, so a client that connects late starts empty.
- One global room, and
CheckOriginaccepts every origin, which is fine on localhost and not anywhere else. There is no ping/pong or read deadline either, so a connection dropped without a close frame lingers.
backend/main.go WebSocket upgrade, read and write pumps
backend/hub.go the fan-out hub
frontend/src/position.ts string fractional positions
frontend/src/CrdtEngine.ts the CRDT: insert, delete, tombstones, diffing
frontend/src/SimulatedNetwork.ts delayed, reordering, disconnecting network for the demo
frontend/src/App.tsx three-replica demo, and single-editor relay mode
MIT. See LICENSE.
Abir Deol · abirdeol.tech