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
npm run fuzz -- mutants --seeds 2000 variant result first seed seeds failed caught by correct clean - 0 of 2000 every check held

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 ""

    The five edits were picked for this page. Every op is printed exactly as the repo's 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 pearsapples on 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

    The words were picked for this page; every id, order and result comes from running the repo's 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

    Values picked for this page, run on the repo's 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"
      Return values, pending counts and text are what the repo's 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 test fails 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

      1. Convergenceevery replica's state is identical, tombstones and their order included
      2. Order independencea fresh replica fed every op in a random order, twice, reaches the same state
      3. Nothing lostthe visible characters are exactly those inserted and never deleted, each once
      4. Local intentevery local edit does what a plain string edit would
      5. 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
      The table is 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, so npm start is 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

        A script written for this page started the repo's relay, unchanged, and connected 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

        1. OnlineA writes, B sees it as it is typed
        2. Both offlineeach edits its own copy
        3. Back onlineboth copies merge

        Browser Aonline

        Browser Bonline

        Text, title and tag are the test's own inputs and the values it asserts. The merged order, coffee before bread, is what six captured runs produced: B's ids were higher because it typed more first. The test only requires both lines, in the same place on both sides.

        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.