RHRio Rheza Harris

Todo List

An availability-first take on the same problem space, opposite tradeoff.

The problem

You and a coworker are sharing a todo list for a trip you're planning together. You're on a flight with no wifi. You add three tasks. They're at their desk, adding two more, at the same time. Neither of you can see what the other is doing. You land, reconnect — what happens?

In the worst version of this app, one of you gets an error. Or your changes silently vanish, overwritten by the other person's. Or the app just refuses to let you add anything at all while offline, because it can't guarantee the list stays "correct."

This is a generic shape, not a todo-list-specific problem: shared documents, shopping carts, kanban boards — any multi-user app where the network between two people's devices can't be trusted to always be there. The question underneath all of them is the one distributed systems people argue about constantly: when the network breaks, do you stay available and reconcile later, or stay strictly consistent and refuse to work?

This project is a working answer to that question — a todo list where two people can edit the same list while completely disconnected from each other, and when they reconnect, everything merges back together automatically, deterministically, with nothing lost.

           Network Partition
                  |
    +-------------+-------------+
    |             |             |
 [Tab 1]          |           [Tab 2]
 adds todos       |           adds todos
 offline          |           offline
    |             |             |
    +-------------+-------------+
                  |
              Partition
               Heals
                  |
         Automatic Merge
         (OR-Set + LWW)
                  |
         Both sides converge
         — nothing lost

Why it matters

Most collaborative apps quietly pick the safe-looking option: reject writes when the network is degraded, so every client stays in lockstep. It reads as more "correct," but it means the app becomes unusable exactly when it's needed most — spotty wifi, a flight, a basement with no signal. Availability gets sacrificed for a consistency guarantee most users never actually asked for.

I built this back to back with Bank Ledger, a project that answers the same CAP-theorem question the opposite way — a distributed ledger that refuses writes the instant it can't confirm consistency with quorum, because for money, a wrong number is worse than a rejected request. Building both sides of that decision, on the same kind of infrastructure, is what made the tradeoff concrete instead of theoretical: AP and CP aren't "better" and "worse," they're answers tuned to what a wrong answer costs. A todo list that briefly disagrees with itself is a minor annoyance. A ledger that does the same thing is an incident. The engineering skill isn't picking one side by default — it's recognizing which side a given system actually needs, and being able to build the mechanism for either one.

Choosing AP over CP isn't a compromise, it's a deliberate bet that your merge logic can be trusted to resolve conflicts correctly later, so you never have to tell a user "no" in the moment.

Key design decisions

Operations as the source of truth, not state. The first decision was how replicas should even talk to each other. One option — state-based CRDTs — has each client ship its entire current state and merge wholesale. I chose the other option: op-based CRDTs, where every change (add an item, delete an item, mark it complete) is recorded as an immutable, timestamped Op, and replicas exchange only the ops they're missing, tracked with a per-client vector clock ({clientID → seqNum}). The payoff shows up exactly at the moment it matters most: when a partitioned client reconnects, it sends only what it buffered while offline, not the whole list. It also turns the operation log itself into a debuggable artifact — the ops table in Postgres is literally a replay log of everything that ever happened to a list. The cost is that the log grows forever unless something eventually compacts it, and every op needs a way to be recognized as "already seen" so a retried sync doesn't double-apply it — solved here with a UNIQUE (client_id, list_id, seq_num) constraint that turns duplicate delivery into a harmless ON CONFLICT DO NOTHING.

Client clock: { "tab1": 5, "tab2": 2 }
Server clock: { "tab1": 5, "tab2": 3, "tab3": 1 }

→ Server sends: tab2's op #3, tab3's op #1
→ Client sends: nothing missing from server

Deletions lose to adds, on purpose. The harder question was what happens when one person deletes a todo item at the same moment someone else adds one. I used an OR-Set (Observed-Remove Set): every ADD_ITEM mints a fresh UUID as that item's tag, and a DELETE_ITEM can only remove a tag it has actually observed. That makes "add wins" fall out almost for free — a delete aimed at an old item and a concurrent add of a new item were never going to collide in the first place, because they don't share a tag. The real tradeoff is smaller and stranger than it sounds: if a delete for item X arrives at a replica that has never heard of X (because the add hasn't synced yet), the delete is simply a no-op — and if the add then arrives afterward, item X reappears. I judged that surprising an occasional "wait, I thought I deleted that" is a smaller cost to a todo-list user than the alternative failure mode — a task someone just added getting silently erased because it collided with an unrelated, older delete.

Silent last-write-wins over exposing every conflict to the user. For fields that mutate — a task's title, its completed state, its order key — each field is its own LWW-Register: list_items carries a separate wall-clock timestamp per field (title_wall_time, complete_wall_time, order_wall_time), and an incoming op only applies if its timestamp is strictly newer than the one already stored. Two people editing different fields on the same item never conflict at all; two people editing the same field concurrently means one edit silently wins and the other is discarded. The alternative, a Multi-Value Register that keeps both values and asks the user to pick, is more "correct" but pulls in a much harder problem — essentially character-level collaborative text editing, the kind Google Docs solves — that would have swallowed the project and distracted from the thing actually being demonstrated: partition tolerance and CRDT merge behavior. For short todo titles edited by a small group, silently picking a winner is an acceptable UX cost for keeping the system's core idea legible.

Postgres as both the op log and the read model, updated atomically. Every sync writes to two tables in one transaction: ops (append-only, the source of truth) and list_items (a materialized snapshot for fast reads). The snapshot write is itself guarded by the same LWW comparison as the in-memory merge — the UPDATE ... WHERE EXCLUDED.title_wall_time > list_items.title_wall_time clause means even a duplicate or out-of-order write can't regress a field that's already newer in storage. That guard is what makes replaying the same op twice, or replaying ops out of order, safe.

What I'd do differently

Being upfront about what this project doesn't solve:

  • A sync failure is invisible, and I found this the hard way while recording the demo for this write-up. The handler's apply loop is if err := h.store.InsertOpAndApplySnapshot(...); err != nil { continue } — any failure to insert an op (a bad client_id, a dropped connection, anything) is swallowed, not surfaced. Meanwhile the client clears its buffer the moment it gets a 200 back, regardless of whether the ops inside it actually persisted. I hit this directly: an invalid client_id had ended up cached in a browser's localStorage from an earlier session, and every op from that browser silently failed the ops.client_id foreign-key check server-side while the UI kept reporting normal syncs — the todos looked added locally, and just never existed anywhere else. That's the opposite of the guarantee this whole project is built to demonstrate. The real fix is for InsertOpAndApplySnapshot errors to come back to the caller as a per-op failure the client can see and retry, instead of a same-as-success 200.
  • Clock skew. The "last write" in Last-Write-Wins depends on each client's own Date.now()-style clock, which can drift. Two truly concurrent edits with identical timestamps get an arbitrary tiebreak (op_id string comparison) rather than a principled one. A production system would replace wall-clock timestamps with Hybrid Logical Clocks, which tighten the ordering guarantee without needing synchronized clocks across devices.
  • The operation log grows forever. Nothing compacts old ops into a snapshot and discards them — the ops table is append-only with no retention policy. That's a well-understood problem (periodic snapshot + truncate), just one I scoped out to keep the project focused on the merge logic itself.
  • Fractional indexing will eventually run out of float precision. Item order is a DOUBLE PRECISION column, and inserting between two items just takes the arithmetic midpoint of their keys. That's fine for normal use, but repeatedly inserting into the same gap (someone reordering the same two items over and over) will eventually produce two keys so close together that float precision can't distinguish them. The standard fix is string-based fractional indexing (keys like "a5", "a5m") with effectively unbounded precision — I used floats because the demo never needed more than a few dozen items.
  • No real authentication. Identity is a username typed into a login screen and a client_id stored in localStorage — there's no password, session, or token anywhere in the request path. A production version would tie client_id to a properly authenticated identity rather than trusting whatever the browser presents.
  • No test suite. There isn't a single test in the repo — not on the CRDT merge logic, not on the sync handler. For a project whose entire point is "the merge logic can be trusted," that's the most honest gap to name: the merge semantics are currently proven by manual poking at two browser tabs, not by anything that runs in CI. Property-based tests that generate random interleavings of ops and assert convergence would be the first thing I'd add.
  • One server, not really multi-region. The CRDT logic is already written to support multiple independent replicas syncing with each other — the vector-clock exchange doesn't assume a single authority — but the actual deployment here is one Go process and one Postgres instance. Getting to genuine multi-region availability would mean replicating the server itself, not just the clients, which is the harder half of the problem this project doesn't attempt.

The first item on that list is a real bug, not a scope decision — I'd fix it before calling this "production-adjacent." Everything below it is a line I drew on purpose, so the project could stay legible as a demonstration of one specific idea rather than becoming a from-scratch Google Docs.

Try it yourself

This is the same partition/heal cycle described above, recorded straight off a running local instance — not a mockup:

Screen recording of two browser tabs sharing a todo list: one tab is put into a simulated network partition, both tabs add different todos while disconnected, then the partition is healed and both tabs converge to the same merged list

git clone https://github.com/riorhezaharris/todo-list
cd todo-list
docker compose up --build      # Go server + PostgreSQL

# in a second terminal
cd frontend && npm install && npm run dev

Open http://localhost:5173 in two browser tabs, join the same list from both, then use the Simulate Partition button on one tab. Add conflicting todos on both sides, click Heal Partition, and watch both tabs converge to the same state live.

This site uses cookies for analytics. See the privacy page for details.