crdt-notes
A local-first notes app. Every browser keeps the whole notebook, works with no network at all, and merges with the others when it can reach them. The merging is a CRDT written from scratch in TypeScript, and a fuzzer checks that replicas converge under a hostile network.
“Eventually consistent” is easy to claim and hard to check. A CRDT that merges correctly in every demo can still diverge when two edits land on the same character, a delete overtakes the insert it deletes, or a message turns up twice. So convergence is treated as a claim to test, and six known ways to get it wrong sit behind switches to prove the test would notice.
Plays the seven stages below in order, about 75 seconds. Scroll or press Esc to stop.
- Language
- TypeScript, Node 22.22+
- Tests
- 37 unit tests and 2 browser tests, passing
- CRDT dependencies
- None at runtime
- App
- Angular with signals, IndexedDB
- Fuzzer
- 5,000 seeds and 6 deliberate bugs in CI
- License
- MIT
All three are real runs at commit 5991483 on Node 22. npm's banner lines are left out and vitest's paths shortened; times are this machine's.
1Every edit is an op with an id
A replica holds every note, and every change is an op: create a note, set a title, add or remove a tag, insert text, delete text. A replica's state is just its ops, applied in order, so there is no merge function to get wrong.
- Each op has a Lamport id: a counter bigger than any the replica has seen, plus the replica's id to break ties. Ids are unique and totally ordered, and an op's id is greater than that of anything it could have known about.
- An insert of four characters takes four ids, so the next op's counter jumps past them.
- An insert names the character it goes after, never an index. Indexes shift under concurrent edits; ids don't.
- A delete only marks its characters as deleted. The tombstone stays in the sequence.
Five edits on replica a Illustrative input
The note's sequence, tombstones included
Visible text ""
Replica made it, and the sequence line is its own snapshot: 3@a ~4@a 5@a 6@a, where ~ marks the deleted “i”.2Concurrent inserts: the greater id goes first
Text is a sequence CRDT called RGA, the Replicated Growable Array. Each character is an element with an id. When two replicas insert after the same character at once, the greater id goes first, and everything typed after it stays with it.
- A new character goes right after its anchor, then skips over any run of characters with greater ids. Every replica makes that choice the same way, whatever order the ops arrived in.
- Because a deleted character stays as a tombstone, an insert typed next to a character someone else just deleted still has something to anchor to.
- Typing forward keeps runs together: “apples” and “pears” typed at once at one spot came out
pearsappleson both sides, not interleaved.
The switch turns on one of the repo's deliberate bugs. Each is a real flag on the same Replica class, there so the fuzzer can prove it would notice.
Same ops, two delivery orders Illustrative input
Replica. The bugs are arrivalOrderTieBreak for the first case and forgetTombstones for the second.3Titles keep the highest id, tags let adds win
A note's title and its deleted flag are last-writer-wins registers: the value with the greatest op id wins, wherever and whenever it arrives. Tags are an observed-remove set.
- Deleting a note is a flag, so it can be undone, and an edit made offline to a note someone else deleted isn't lost.
- A tag removal lists the adds its replica had seen, and cancels only those. A tag added concurrently with its removal survives.
- “Last writer” means the greatest id, not the last op to arrive. Arrival order differs from replica to replica; ids don't.
Two races on one note Illustrative input
Replica; the ops and results are as printed. The bugs are lwwByArrival and removeUnobserved.4Hold each op until what it depends on has arrived
Each op carries the version vector of what its replica had applied. A receiver holds an op until everything it depends on is there, so an insert never arrives before the character it follows, and a delete never before what it deletes.
- An op is ready when it is the next one from its replica and every count in its version vector is already met. Applying one can release others that were waiting.
- Duplicates are recognised by replica and sequence number, and ignored.
- A malformed op, or one that names a note or character that still isn't there once its dependencies are, is rejected before it touches anything: the log, the version vector and the notes are as if it never arrived.
- The same rule rebuilds a replica after a reload: the app replays its op log from IndexedDB.
Out of order, then twice Illustrative input
Arriving at replica b
Waiting
Applied
The two bugs this rules out, same kind of input
noCausalBuffer: “c” arrives before “ab”- sender
"abc"receiver"c" acceptDuplicates: each op delivered twice- sender
"once"receiver"onceonce"
Replica printed. Without the buffer, “c” was applied with its anchor missing, then “ab” looked already seen by sequence number and was dropped.5Call it converged only after five checks on a hostile network
This is the line. Replicas that agree in one demo prove little, so the claim “these converge” is held back until a fuzzer has attacked it. A seed describes a whole run: 2 to 5 replicas editing a few notes while a network delays, reorders, drops and duplicates their ops, and partitions come and go. Then the network heals, and five checks run.
- Each seed draws its own profile, so some runs are calm and some are a storm of one kind: drops and duplicates up to 30% each.
- Convergence alone is easy to fake. Replicas that ignore every remote op all agree, on nothing. That is why nothing lost and local intent are checks too.
- Convergence compares tombstones and their order. With the arrival-order bug, seed 7 diverges with identical visible text; the next insert near those characters would have shown it.
npm testfails if any deliberate bug survives 200 seeds. CI runs the correct build on 5,000.
The fuzzer is evidence, not proof, and the README says so. The mutants show it would catch these six mistakes; a mechanised proof would show there are no others.
The correct CRDT and six deliberate bugs, 2,000 seeds each
- Convergenceevery replica's state is identical, tombstones and their order included
- Order independencea fresh replica fed every op in a random order, twice, reaches the same state
- Nothing lostthe visible characters are exactly those inserted and never deleted, each once
- Local intentevery local edit does what a plain string edit would
- Nothing stuckno replica is left holding ops it can't apply
- Seeds, correct CRDT
- 5,000 clean
- Ops made
- 1,590,686
- Dropped
- 1,096,483
- Duplicated
- 460,050
- Partitions
- 14,767
npm run fuzz -- mutants --seeds 2000, and it matches the README's. The split by check and the totals over seeds 1 to 5,000 were counted for this page by calling the repo's own fuzz() on every seed. Dropped includes ops lost to partitions.ClearedAbove this line every replica lives in one process and the fuzzer plays the network. Below it, ops leave the replica: through a relay that decides nothing, into real browsers that go offline and come back.
6Store and forward, with no authority
The relay stores ops and forwards them. It doesn't merge, order or reject any edit, and it doesn't have to stay up. It can lose its data without losing an edit, as long as some replica still holds it.
- On connect, each side sends its version vector and receives what it lacks. After that, new ops are forwarded to everyone but the sender, as they arrive.
- Ops go to an append-only file and are reloaded on start. A torn last line from a crash is skipped; the replica still has that op.
- The one thing it refuses is a message whose ops aren't well-formed. It closes that socket and stores and forwards nothing, so a broken client can't send other replicas an op they would have to throw on. Garbage gets the same treatment, and the relay keeps serving.
- It is 189 lines on
ws, and it also serves the built app, sonpm startis the whole demo.
No authentication or encryption: anyone who can reach the relay can read and write every note. The README says a real deployment would put it behind auth, and could encrypt ops end to end, since the relay never reads them.
Two clients, a restart, a newcomer, two bad senders Illustrative input
Relay stores0 ops
ws clients backed by real replicas. Every message is as sent, with each op shortened to its type, id and sequence number. B's second send of the same op was stored once and forwarded to no one. E's op has no type, note or version vector: the relay closed E's socket, stored nothing and sent C nothing.7Two browsers edit offline, then agree
The Playwright test runs the real app against the real relay in two browser contexts with separate storage. Both go offline, both edit the same note, and both come back.
- Every keystroke becomes at most one delete and one insert, diffed against the current text.
- When a remote edit changes the text you are typing in, your cursor stays next to the same characters, not at the same offset. The selection is held as the ids of the characters before each end.
- The notes live in IndexedDB: the test reloads a page while offline and the merged note is still there. A second test makes an edit with the relay unreachable and checks it arrives later.
The end-to-end test, step by step
- OnlineA writes, B sees it as it is typed
- Both offlineeach edits its own copy
- Back onlineboth copies merge
Browser Aonline
Browser Bonline
37 unit tests, 2 browser tests
Counted with npm test (vitest, in the CRDT package and the relay) and npm run e2e (Playwright). No network beyond localhost, no keys.
What they pin
- The CRDT: local edits behave like a string, surrogate pairs included; concurrent inserts, typed runs that don't interleave, an insert next to a concurrent delete, title races, add-wins tags, delete and restore.
- Delivery: causal buffering, including a dependency through a third replica; duplicates ignored; a replica rebuilt from its op log keeps numbering where it left off; malformed ops, and ops naming a note or character it doesn't have, are rejected and leave its state unchanged.
- The fuzzer itself: 500 seeds clean, the same result for the same seed, runs that are actually hostile (over 100 seeds: more than 10,000 ops, 2,000 dropped, 1,000 duplicated, 50 partitions), and each of the six bugs caught.
- The relay: forwards but doesn't echo, a newcomer gets exactly what it lacks, each op stored once, restart from the data file, garbage closes the socket, malformed ops neither stored nor forwarded, no path traversal.
- A known limit: one test pins that RGA interleaves concurrent backward typing, so a change in that behaviour is noticed.
CI runs typecheck, tests and the build on Node 22 and 24, the 5,000-seed hunt and the mutant table, and the Playwright tests in Chromium.
Deliberately missing
- Backward typing
- Two people typing at the same spot by repeatedly inserting at the start can interleave. Fugue avoids this and RGA doesn't. Two strings typed backwards at once came out
xaybzc. - Garbage collection
- Tombstones and the op log are kept forever. Collecting them needs to know every replica has seen a delete, which takes membership tracking this project doesn't do.
- Rich text
- Plain text only. No formatting marks and no move operation: moving a paragraph is a delete plus an insert.
- Authentication
- None on the relay, and no encryption. Anyone who can reach it can read and write every note.
- Long documents
- One element per character is fine for notes. A book would want run-length encoded elements.
Run it
git clone https://github.com/ahmed-hashim-pro/crdt-notes && cd crdt-notes npm ci npm test npm run build npm start # Open http://127.0.0.1:8787 in two browsers, or a normal and a private window. # The switch at the top right takes either one offline. npm run fuzz -- mutants --seeds 2000 npm run fuzz -- run --seed 7 --bug arrival-order-tie-break npx playwright install --with-deps chromium npm run e2e
Needs Node 22.22 or newer. The CRDT package has no runtime dependencies; the relay uses ws.