Scale Interview
System DesignMedium

Design a Collaborative To-Do List

Real-Time Shared List System Design

Overview

A collaborative to-do list lets several people read and edit the same list at the same time and see each other's changes appear live. Picture two roommates sharing a chores list: one checks off "take out the trash" while the other adds "buy milk" and drags a task higher up the list, and within a second each sees what the other did. This is the shared-list feature behind apps like Todoist, Google Keep, or a shared Notion checklist.

It is labeled "Medium" because the core problem — several people editing shared state and converging on the same result — is real, but the granularity is much friendlier than a document editor. In Google Docs two people can type into the middle of the same word, so you must merge edits character by character. In a to-do list the unit of change is a whole item: check it, rename it, delete it, move it. Two people rarely edit the exact same field of the exact same item at the same instant, and when they do, "the newer edit wins" is usually an acceptable answer. That means you do not need the heavy machinery (operational transformation) that a text editor needs — item-level conflict resolution is enough.

In this walkthrough you'll scope the list, size it, model the data, define the sync protocol (which is not plain REST), draw the edit-and-broadcast flow, and then go deep on the three things that actually take thought: the real-time transport and fan-out, item-level conflict resolution, and reordering plus offline merge.

Out of scope (state it, so you don't burn time on it): rich task metadata (due dates, labels, attachments — they're just more fields), notifications and reminders, deep permission models beyond "who is a member of this list," and full text search. We keep real-time sync, conflict resolution, reordering, and offline firmly in scope.

Functional requirements

  • Multiple people edit one list at once. Several members open the same list; each can add an item, edit its text, toggle it done/undone, delete it, and reorder items. Every change propagates to the others in real time.
  • Everyone converges to the same list. However edits interleave, all members (and the stored copy) end up seeing the same items in the same order. No change is silently lost.
  • Reordering. A member can move an item up or down; the new order shows for everyone, and two people moving items at once don't corrupt the ordering.
  • Membership. A list has a set of members who may read and edit it; only members can see or change it.
  • Offline editing. A member can keep editing while disconnected (add, check off, reorder), and those changes merge in cleanly when they reconnect.

Non-functional requirements

  • Low-latency propagation. A change made by one member should appear on the others' screens in well under a second — target < 500 ms p99 for members in the same region. This drives the persistent-connection design.
  • Eventual consistency, no lost updates. Once every member has received the same set of changes — in any order — they must hold the same list. Convergence is the correctness property, and it must not be achieved by throwing edits away.
  • Durability. Once the server accepts a change, it must survive a crash and never be dropped. We persist before we treat a change as committed.
  • Responsiveness under local edits. Checking a box must feel instant — the local app applies the change immediately (optimistically) and reconciles with the server in the background, so the user never waits for a round trip to see their own tap register.
  • Horizontal scale. Many lists, each with a small number of members. The system scales by adding machines, and no single list should overwhelm one server.

Estimations

State assumptions; the goal is to justify the persistent-connection layer, the pub/sub fan-out, and the op-log store — not to be exact. The headline fact is that this workload is small per list: the challenge is real-time correctness, not raw throughput.

Rounded assumptions

  • Daily active users (DAU): ~10M, each touching a handful of lists per day.
  • Collaborators per list: small — typically ~2–5, rarely more than a few tens. A shared to-do list is not a 10,000-person group; the fan-out per change is tiny.
  • Edits per second per list: low. Even an actively-edited list sees maybe ~1 edit/sec during a burst — a person checks off items a few times a minute, not a few times a second. This is nothing like a text editor's ~5 keystrokes/sec.
  • Concurrent connections: if ~1M members are viewing shared lists at peak (assuming ~10% of the ~10M DAU are concurrent at peak), that's ~1M live connections — the connection count is the real scale number, not the edit rate. At ~65K sockets per connection server (an idealized ceiling; real deployments run lower) that's only ~15–20 connection servers, so even the headline scale number is modest.
  • Items per list: typically tens, occasionally a few hundred. Small enough that a full list fits comfortably in one payload.

How the numbers affect the design

Signal from the numbers Design decision
~1M members viewing at once, each on a live list A layer of connection servers holding long-lived WebSocket connections, not request/response web servers.
Few collaborators per list, low edit rate Fan-out is cheap — no need for a per-list sequencer or heavy fan-out machinery; broadcast each change to the handful of members.
Members of one list may connect to different servers A shared pub/sub layer keyed by list id, so a change on one server reaches members held on another.
Small lists (tens of items) A whole-list snapshot is a small payload; catch-up on reconnect can send state or a short op tail cheaply.
Changes are small and frequent-ish Store an append-only op log per list (cheap to append, replayable for catch-up), plus the current materialized list.

Storage

  • Per item: item id (~16 bytes), list id (~16 bytes), text (~100 bytes), done flag (1 byte), order key (~16 bytes), updated_by (8 bytes), item_version logical clock (8 bytes) ≈ ~165 bytes. Round to ~200 bytes with overhead.
  • Per op in the log: list id, item id, op type, the changed field(s), an op id, an item_version~64 bytes. Even a very active list produces only kilobytes of ops per day — the op log is tiny compared to a chat or document system.

Note

Do not over-engineer the scale here. Unlike a chat app (~750K messages/sec) or a document editor (orders of magnitude more ops), a to-do list's per-list write rate is a trickle. The interviewer is testing whether you can get correctness (convergence, reordering, offline merge) right on a small, simple workload — not whether you can shard a firehose. Reaching for a per-list sequencer or Kafka-scale infrastructure is a signal you've misread the problem.

Core entities / data model

Entity Key fields
List list_id (PK), title, owner_id, created_at
Item item_id (PK), list_id, text, done (bool), order_key (sortable string), updated_by, item_version (per-item logical clock), deleted (tombstone flag)
Membership (list_id, user_id), role (owner/editor), joined_at — who may open and edit the list
Operation list_id, op_id (client-generated unique id), type (add/edit/toggle/delete/reorder), item_id, changed fields, item_version, author_id

The interesting fields hang off Item. order_key is a sortable string that decides an item's position (Deep Dive 3 explains why it's a string, not an integer index). item_version is a per-item logical clock — a Lamport or hybrid-logical clock, not wall-clock time — used to decide which of two concurrent edits to the same field wins. Keep it distinct from the per-list log_seq (the sequence number of the op log, used as the reconnect cursor): item_version breaks LWW ties on one item; log_seq tells a reconnecting client what it has already seen. deleted is a tombstone — a "this item is gone" marker kept instead of hard-deleting the row, so a concurrent edit that arrives after the delete doesn't accidentally bring the item back.

The Operation log is an append-only, per-list record of every change. Each op carries a client-generated op_id used to deduplicate retries (the same op replayed on reconnect must not apply twice) and an item_version for conflict resolution. Its position in the log is the per-list log_seq, the source of truth for catch-up (replay ops since a client's last-seen log_seq) and for rebuilding state.

Note

Separate the materialized list (the current items, for a fast load) from the op log (the append-only history of changes, for catch-up and merge). The first answers "what does the list look like now"; the second answers "what changed since this client last synced." A small system can even derive the first by replaying the second, since lists are small.

API design

Loading a list and managing membership are ordinary request/response calls, but editing can't be pure request/response: you don't want to poll for other people's changes, and you must receive their edits the moment they happen. So the heart of the contract is a WebSocket protocol carrying operations both ways, described alongside the REST endpoints.

Sync channel (persistent connection)

Client opens a WebSocket:  wss://lists.example.com/connect?list=<list_id>&since=<log_seq>
  → authenticates (token), server checks Membership, then keeps the connection OPEN
  → server replays any ops after <since> so the client catches up, then streams live

Client → server events:
  { "type": "op", "op_id": "<uuid>", "op": { "kind": "add",     "item_id": "i9", "text": "buy milk", "order_key": "an" } }
  { "type": "op", "op_id": "<uuid>", "op": { "kind": "toggle",  "item_id": "i4", "done": true,  "item_version": 171... } }
  { "type": "op", "op_id": "<uuid>", "op": { "kind": "edit",    "item_id": "i4", "text": "call plumber", "item_version": 171... } }
  { "type": "op", "op_id": "<uuid>", "op": { "kind": "delete",  "item_id": "i2", "item_version": 171... } }   // tombstones i2's add-tag; since a re-add mints a fresh item_id, item_id doubles as the add-tag, so this delete can't suppress a later re-add
  { "type": "op", "op_id": "<uuid>", "op": { "kind": "reorder", "item_id": "i4", "order_key": "am5", "item_version": 171... } }
  { "type": "ping" }                                              (heartbeat)

Server → client events (pushed, no request):
  { "type": "op",  "log_seq": 4210, "author_id": "...", "op": { ...applied op... } }   (a collaborator's change)
  { "type": "ack", "op_id": "<uuid>", "log_seq": 4210 }                                (your op committed)
  { "type": "presence", "user_id": "...", "status": "viewing" }

The client opens one WebSocket per open list and keeps it open. Each user action becomes one small op tagged with a client-generated op_id. The server applies it, appends it to the log, acks the sender, and broadcasts it to every other member of that list. Edits and toggles carry an item_version so concurrent changes to the same field resolve deterministically (Deep Dive 2); reorders carry a new order_key (Deep Dive 3). A ping heartbeat keeps the connection alive.

Load a list (join)

GET /api/lists/{list_id}
Returns: 200 {
  "list_id": "...",
  "title": "Chores",
  "items": [ { "item_id": "i4", "text": "call plumber", "done": false, "order_key": "am", "item_version": 171... }, ... ],
  "log_seq": 4208
}

Fetch the full list to render it. Because lists are small (tens of items), returning the whole materialized list in one response is cheap. The client then opens the WebSocket with since=4208 (the list's log_seq) to receive everything after that.

Create a list

POST /api/lists
Body:    { "title": "Chores" }
Returns: 201 { "list_id": "...", "log_seq": 0 }

Create an empty list; the creator becomes the owner and first member.

Manage members

POST /api/lists/{list_id}/members
Body:    { "user_id": "...", "role": "editor" }
Returns: 200 { "list_id": "...", "members": [ ... ] }

Add a member so they can open and edit the list. Membership is the gate the sync channel checks on connect.

High-level architecture

Two flows matter: a change propagating from one member to everyone else (the sync path), and routing that change across servers when members are on different machines (the fan-out path). The components are shared:

  • Connection servers (the gateway layer). Hold the long-lived WebSocket connections and shuttle ops to and from clients. Dumb pipes — they move events but don't own correctness.
  • List service. Validates each op, applies conflict resolution, appends it to the op log, updates the materialized list, and hands the applied op to the fan-out layer. This is where the durable logic lives.
  • Pub/sub layer. A shared bus (e.g. Redis pub/sub) with one channel per list. Every connection server holding a member of a list subscribes to that list's channel; a committed op is published once and every subscribed server re-broadcasts it to its local sockets.
  • Store: op log + materialized list. The durable, append-only op log per list, plus the current items. The source of truth.

1. Edit-and-broadcast flow

One member checks off an item; it must reach the other members within a second.

flowchart LR
    Alice["Alice's app"] -->|"op: toggle i4 done"| GWA["Connection Server A"]
    GWA -->|"forward op"| Svc["List Service"]
    Svc -->|"resolve conflict, apply"| Svc
    Svc -->|"append op, update item"| Store[("Op Log + Items<br/>by list_id")]
    Svc -->|"ack (log_seq)"| GWA
    GWA -->|"ack"| Alice
    Svc -->|"broadcast op"| GWB["Connection Server B"]
    GWB -->|"push op"| Bob["Bob's app"]
    style Svc fill:#e0f2fe,stroke:#0284c7,color:#0c4a6e
  1. Alice's app applies the toggle locally and immediately (optimistic UI), then sends the op over its WebSocket to Connection Server A, tagged with an op_id.
  2. Server A forwards it to the List Service.
  3. The List Service applies conflict resolution — for a toggle, keep the change with the newer item_version (Deep Dive 2) — updates the item, and appends the op to the durable log before acknowledging, so an accepted change is never lost.
  4. It acks Alice with the committed log_seq.
  5. It broadcasts the applied op to every other member; Bob's app receives it and applies it, and both converge.

2. Cross-server fan-out flow

The members of one list may be connected to different connection servers. Server A can't push to Bob directly, because Bob's socket lives on Server B. A shared pub/sub layer, keyed by list id, solves this.

flowchart LR
    Alice["Alice's app"] -->|"op"| GWA["Connection Server A"]
    GWA -->|"apply + append"| Svc["List Service"]
    Svc -->|"publish to<br/>channel list.42"| Bus[("Pub/Sub Bus<br/>channel per list")]
    Bus -->|"deliver"| GWA2["Connection Server A<br/>(subscribed to list.42)"]
    Bus -->|"deliver"| GWB["Connection Server B<br/>(subscribed to list.42)"]
    GWA2 -->|"push to local members"| Carol["Carol (on Server A)"]
    GWB -->|"push to local members"| Bob["Bob (on Server B)"]
    style Bus fill:#e0f2fe,stroke:#0284c7,color:#0c4a6e
  1. Alice's op is applied and appended by the List Service as before.
  2. Instead of the service tracking which server each member sits on, it publishes the committed op once to the pub/sub channel for this list (list.42).
  3. Every connection server that holds a member of list 42 is subscribed to that channel, so the op is delivered to Server A and Server B.
  4. Each server pushes the op down the WebSockets of its local members — Server A to Carol, Server B to Bob.

The subscription is the routing: a server subscribes when it accepts its first member of a list and unsubscribes when its last one leaves. Because collaborator counts are tiny, the number of subscribers per channel is small, so this is cheap.

Note

This pub/sub-per-list approach fits precisely because lists have few members. In a system with huge fan-out (a 10,000-member chat group) you'd weigh a central routing registry against pub/sub. Here the fan-out is a handful of members, so pub/sub keyed by list id is simple and sufficient — don't reach for heavier routing.

3. Combined architecture

Putting the paths together:

flowchart LR
    Alice["Alice"] --> GWA["Connection Server A"]
    Bob["Bob"] --> GWB["Connection Server B"]
    GWA --> Svc["List Service"]
    GWB --> Svc
    Svc -->|"append committed ops"| Log[("Op Log - by list_id")]
    Svc -->|"update materialized items"| Items[("Items store")]
    Svc -->|"publish op"| Bus[("Pub/Sub - channel per list")]
    Bus --> GWA
    Bus --> GWB
    Joiner["New joiner"] -->|"GET list + since=log_seq"| ListSvc["List Service (load)"]
    ListSvc --> Items
    ListSvc --> Log

The List Service is the hub: it applies conflict resolution, persists each op to the log and the change to the items store, and publishes the applied op to the list's pub/sub channel. Every connection server subscribed to that channel re-broadcasts to its local members. A new joiner loads the materialized list plus any ops after its last-seen log_seq, then subscribes for live updates.

Tip

Lead with the single-list happy path (one person edits, others see it), then introduce a second concurrent editor to motivate conflict resolution, then reordering, then offline. Jumping straight to "two people move the same item at once" before establishing the basic broadcast confuses the interviewer about what you're solving.

Deep dives

1. Real-time sync and the transport (the fan-out)

The sync channel is a WebSocket to a connection server. Editing can't be plain REST: the client shouldn't poll for other people's changes, and it must receive them the instant they happen without asking. So each member holds one open WebSocket per list, and the server pushes every collaborator's applied op down it.

The cross-server problem. The members of a list can be spread across many connection servers. When Server A applies Alice's op, it must reach Bob, whose socket is on Server B. The clean solution is a pub/sub layer keyed by list id, as drawn above: every server subscribed to list.<id> re-broadcasts a published op to its local sockets. The subscription is the routing — no separate map of "which member is on which server" to keep fresh. Because a to-do list has only a few members, the subscriber set per channel is tiny and this stays cheap. (In vendor terms, this is a Redis pub/sub channel per list, or any message bus with topic-per-list.)

Presence. Members like to see who else is currently viewing the list ("Bob is here"). Presence is high-frequency and ephemeral — held in memory and broadcast over the same pub/sub channel, never written to the op log or database. If a presence update is dropped, the next one corrects it. Make presence TTL/lease-based: a viewer's entry expires unless it's refreshed by a periodic heartbeat, so it ages out on its own. A clean disconnect still emits a "left" event for instant removal, but you must not rely on that alone — a crashed or network-partitioned connection server never emits "left", so without the TTL its viewers would linger as ghosts. The lease is what reaps them.

Reconnect and catch-up. Connections drop — a phone loses signal, an app is backgrounded. On reconnect, the client does not reload from scratch. It reconnects with the last log_seq it applied, and the server replays the ops after that log_seq so the client catches up, then resumes the live stream. Because lists are small, a simple fallback is just re-sending the whole materialized list and letting the client reconcile — but op-since-log_seq is cheaper and preserves the client's own un-acked local ops.

Client last applied log_seq 4208, then lost connection.
Meanwhile the list advanced to log_seq 4213 (5 ops committed while away).
On reconnect: GET/subscribe with since=4208.
Server replays ops 4209..4213 in order → client is caught up.
Then the live stream continues from 4214 on.

Warning

Do not have each connection server poll the database for changes to its lists. Even at this modest scale, polling wastes work and adds latency. Delivery must be push: the applied op is published to the list's channel and forwarded to members' open sockets.

2. Conflict resolution at the item level

This is the crux — and the reason a to-do list is Medium, not Hard. Concurrent edits are the hard part, but at item granularity, not character granularity. Two people rarely edit the exact same field of the exact same item at the same instant, and when they do, a simple rule resolves it. You do not need operational transformation (the character-level machinery a text editor needs). Below are the approaches, worst → best.

Last-write-wins on the whole list (avoid)

Treat a save as "here is my entire new list," and whenever two saves arrive, keep the one with the later timestamp. No per-item merge at all.

Worked example:

Base list: [ "trash" (done=false), "milk" (done=false) ]
Alice toggles "trash" done  → sends whole list: [ trash done=true,  milk done=false ]  at t=1
Bob   adds  "eggs"          → sends whole list: [ trash done=false, milk done=false, eggs ]  at t=2 (later)
Server keeps Bob's whole-list save → [ trash done=false, milk done=false, eggs ]
→ Alice's toggle is GONE. She checked a box and it silently un-checked.
  • Pro: trivial to implement — one list blob, keep the newest write.
  • Con: it loses concurrent updates. Any change made against a slightly stale copy is discarded the moment someone else's whole-list save lands. Two people editing at once constantly clobber each other. It violates the core requirement — everyone converges, but only by throwing edits away.
  • Verdict: avoid. This is the anti-pattern the question exists to rule out. The unit of change must be a single item op, not a full-list overwrite.

Caution

Last-write-wins on the whole list is the most common wrong answer here. Converging is not enough — you must converge with each person's change preserved. Whole-list LWW silently drops concurrent edits. The fix is to make conflict resolution operate per item (and per field), never on the list as a whole.

Last-writer-wins per field/item, by item_version (works, with caveats)

Keep the change unit small: each op targets one item and one field. Give each item (or field) an item_version — a per-item logical clock (a Lamport or hybrid-logical clock bumped on every change and advanced on merge), not wall-clock time and not a bare per-client counter. When two ops touch the same field of the same item, the one with the higher item_version wins; ops to different items or fields never conflict at all and both simply apply. The logical clock matters precisely so a client that was offline for a long time — with a fast or stale wall clock, or a counter that lags everyone else's — can't replay a stale edit that beats newer edits.

Worked example:

Alice toggles "trash" done  (op on item i-trash, field done, item_version 41)
Bob   edits   "milk" → "buy milk"  (op on item i-milk, field text, item_version 12)
→ Different items → BOTH apply. No conflict. Everyone converges to
   [ trash done=true, "buy milk" done=false ].

Now the genuine conflict — both toggle the SAME item:
Alice toggles i-trash done=true   item_version 41
Bob   toggles i-trash done=false  item_version 43  (higher logical clock)
→ Same item + field → keep higher item_version → done=false wins.
  • Pro: simple, and correct for the common cases. Edits to different items never conflict, so most concurrent activity just merges. For a single field like done or text, "newest wins" is a reasonable, predictable rule.
  • Con: LWW converges but "converges" is not the same as "keeps the value a human wanted" — when two people edit the same field concurrently, one edit is deterministically dropped, and that's inherent to the register, not a bug you can tune away. If Bob's rename and Alice's toggle-then-rename race on the same field, only one text survives. If you tie-break by wall-clock timestamp instead of a logical clock, clock drift between clients can make an earlier edit win. It also doesn't answer the delete-vs-edit race (below) on its own. You need a deterministic tie-break (e.g. compare author_id when item_versions tie) so every replica makes the same choice.
  • Verdict: works, with caveats — good enough for most fields of a to-do item, provided item_version is a logical clock and the tie-break is deterministic. It's a perfectly defensible interview answer; the next option removes its remaining rough edges.
A small CRDT — LWW-register per field, add-wins set for membership (recommended)

A CRDT (conflict-free replicated data type) packages the good parts of per-field LWW into rules that provably converge, and fixes the delete-vs-edit race. You don't need a character-level CRDT (that's for text). Two small pieces suffice:

  • A LWW-register per field for text and done: keep the value with the highest (item_version, author_id) — the same "newest wins" as above, but with a formal deterministic tie-break, so replicas can't diverge. Because item_version is a logical clock, this holds even after a long offline gap; a plain wall-clock timestamp would not (see the amber option's clock-drift caveat).
  • An OR-Set (add-wins set) for which items exist in the list. It governs the add-vs-remove race only: a concurrent add and remove of the same item resolve so the add survives, and a re-add is not swallowed by an old tombstone. It does not by itself decide edit-vs-delete — that's a separate rule spelled out below.

Two membership rules, kept separate. Add-vs-remove is add-wins (the OR-Set). Edit-vs-delete is a different pairing and uses remove-wins: an edit is a field write, not an add, so the add-wins set says nothing about it — an edit whose item is currently tombstoned is simply dropped (it no-ops). Concretely, every field write is gated on membership: before applying an edit or toggle, the merge checks the OR-Set, and if the target is tombstoned the write is discarded. That gate is what stops an edit from resurrecting a deleted item — the exact thing tombstones exist to prevent. Without it, an "upsert"-style apply would recreate the row.

Worked concurrent-edit trace — the delete-vs-edit race, resolved:

Both start from item i-milk = { text: "milk", done: false }.

  Alice (offline for a moment): edit i-milk text → "buy 2% milk"   (op tag A)
  Bob:                          delete i-milk                       (op tag B)

These are concurrent — neither saw the other. Merge both, in either order:

  This is edit-vs-delete → remove-wins (a separate rule from add-wins):
  Bob's delete tombstones i-milk. Alice's edit is a field write, not an add,
  so it does NOT re-add anything. The merge checks membership BEFORE applying
  the write; i-milk is tombstoned, so Alice's rename is discarded — it cannot
  resurrect the row. Both replicas converge to: i-milk removed.

  Contrast a RE-ADD race, where add-wins DOES apply:
  Alice: delete i-milk                    (tombstones the tag it saw)
  Bob:   re-add "milk" as a NEW item      (fresh item_id, brand-new add tag)
  → A re-add is a new element identity, not the old item_id coming back.
    The tombstone is keyed to the OLD tag, so it doesn't suppress the new one.
  → add-wins: the new add's tag was never tombstoned → the item EXISTS.
    Both converge to: "milk" present, under a new item_id.

Convergence is guaranteed by construction: apply the same set of ops in any order and every replica lands on the same list, without a central serializer resolving conflicts by hand.

Delete-vs-edit and idempotency. Deletes leave a tombstone (the OR-Set remembers which add-tags were removed) and every field write is gated on membership, so a late concurrent edit against a tombstoned item is dropped rather than resurrecting it — remove-wins for that pair. And because ops carry a client-generated op_id, applying an op is idempotent — the same op replayed on reconnect is recognized by its op_id and skipped, so a retry never double-applies (checking a box twice, or adding the same item twice).

  • Pro: convergence is a structural guarantee, not a hand-checked rule; the add-wins set handles delete/re-add races that naive LWW gets wrong; idempotent op ids make retries and offline replay safe. No central serializer needed — merges work peer-to-peer and after long offline gaps.
  • Con: slightly more state (tombstones to track and eventually garbage-collect once no offline client can still reference them; per-field item_version metadata). Marginally more to implement than bare per-field LWW.
  • Verdict: recommended. For a to-do list it's a small CRDT — a register per field plus an add-wins set for membership — so the cost is low and you get provable convergence plus correct delete/re-add behavior. This is the answer that shows you know why item-level is enough and where naive LWW breaks.

Choosing between them. Per-field LWW (the amber option) is a perfectly good interview answer and is what many real apps ship. The small CRDT (green) adds provable convergence and correct delete-vs-edit behavior for a modest cost. What you must not do is whole-list LWW (red), and you must not reach for operational transformation — that's character-level machinery this problem doesn't need.

Warning

Whatever you pick, the tie-break must be deterministic and identical on every replica — e.g. compare author_id when item_versions tie, not "whichever arrived first." Wall-clock timestamps alone are an unsafe tie-break because client clocks drift; that's why item_version is a logical clock. If two replicas break a tie differently, they diverge and convergence is lost.

3. Reordering, persistence, and offline

Reordering is the genuinely tricky part. The naive model is a dense integer position (0, 1, 2, 3…). It breaks in two ways: moving an item to the top means rewriting every other row's position, and two people reordering at once collide on the same integers. Do not store positions as dense integers.

Dense integer positions (avoid)

Each item has an integer position; reordering renumbers the affected rows.

[ a=0, b=1, c=2, d=3 ]
Move d to the top → must rewrite a=1, b=2, c=3, d=0  (four writes for one move).
Meanwhile Bob moves c to the top against the OLD numbering → both write position 0,
and the two moves collide — the merged order is corrupt.
  • Pro: trivial to sort by.
  • Con: every move rewrites many rows (costly and a big op), and concurrent moves collide on integers with no clean merge.
  • Verdict: avoid. It makes reordering both expensive and unmergeable.
Fractional / lexicographic order key (recommended)

Give each item an order_key that is a sortable string, and place an item between its neighbors by generating a key that sorts between their keys — fractional indexing (a Logoot-style order key). Sorting by order_key yields the list order. Moving an item rewrites only its own key; concurrent inserts between the same two neighbors get distinct keys.

Keys are strings compared lexicographically. Start: a="a", b="c", c="e", d="g".
Move d between a and b → give d a key between "a" and "c", e.g. "b".  ONE write: d="b".
   Order by key: a("a"), d("b"), b("c"), c("e")  → a, d, b, c.  ✓

Concurrent inserts between a("a") and b("c"):
   Naively BOTH clients compute the same midpoint (e.g. "b") and COLLIDE
   → duplicate order key, nondeterministic order. The distinct keys aren't luck.
   Fix: seed the generated key with the inserting client's id (or a random
   suffix), so each client deterministically lands on a DIFFERENT key:
   Alice (client A) inserts X between them → key "an"  (between "a" and "c")
   Bob   (client B) inserts Y between them → key "as"  (between "a" and "c")
   Different keys → both survive, sorted deterministically: a, X("an"), Y("as"), b.
   No collision, no renumbering.

Because a key can always be generated between any two others (append more characters to make a finer-grained key), you never run out of room, at the cost of ever-longer keys — and a move touches exactly one row.

Concurrent same-item move. Two users moving the same item at once resolve by LWW on its order_key (the higher item_version wins); the losing move is dropped, which is acceptable — the item lands in one of the two intended spots, and nobody's list is corrupted.

A residual anomaly. Fractional indexing has one known imperfection: if two clients each insert a run of several items into the same gap concurrently, the two runs can interleave (a1, b1, a2, b2…) rather than staying contiguous blocks. It's a rare edge case and entirely tolerable for a to-do list.

  • Pro: a move is one small op (rewrite one key); concurrent inserts/moves get distinct keys and merge cleanly; it composes with the CRDT above — the order_key is just another LWW-register field per item.
  • Con: keys grow in length with repeated inserts into the same gap — this is driven by how often one gap is subdivided, not by the total list size, so you can exhaust precision even on a 5-item list. An occasional background rebalance resets the keys. A minor concern for lists of tens of items.
  • Verdict: recommended. Fractional order keys are the standard way to make reordering cheap and concurrency-safe.

Persistence. Two stores back the list: the materialized items (current state, for a fast load) and an append-only op log (every change, the source of truth for catch-up and rebuild). Every accepted op is appended to the log before it's acknowledged, so an accepted change survives a crash. Because lists are small, you can even rebuild the materialized list by replaying its ops — the log is the authority; the materialized list is an optimization for fast reads.

Offline. A member should be able to edit while disconnected — check items off, add tasks, reorder — and have it all land correctly on reconnect. The mechanism:

  1. Queue local ops while offline, each tagged with its op_id, and apply them optimistically so the local UI reflects them immediately.
  2. On reconnect, replay the queued ops to the server through the same conflict-resolution merge used online. Because ops carry a logical-clock item_version and an op_id, replaying them is idempotent (an op already applied is skipped) and order-independent (the CRDT merge converges regardless).
  3. The server also replays the ops the client missed while away (Deep Dive 1's catch-up), and the two sets merge.
Alice goes offline, then:
   toggles "trash" done   (op A, item_version from her logical clock)
   adds    "eggs"         (op B, new order_key "d")
Meanwhile Bob (online) renames "milk" → "buy milk" and deletes "trash".

Alice reconnects and replays A, B:
   - A toggles "trash": but Bob deleted "trash" (tombstone) → the toggle is a field
     write gated on membership, so against a tombstoned item it's dropped (remove-wins,
     no resurrection); both converge to "trash" removed.
   - B adds "eggs": a fresh add with its own order_key → survives; appears for everyone.
   Alice also receives Bob's rename + delete and merges them.
Final list (everyone): [ "buy milk", "eggs" ] — every change accounted for,
nothing double-applied (op_ids dedup the replay).

Read-your-writes for the acting user. Because the local app applies each op optimistically, the person who made a change always sees it immediately (read-your-writes), even before the server confirms. Everyone else sees it a fraction of a second later once it's broadcast — the system is eventually consistent across members, immediately consistent for the actor.

Warning

Don't garbage-collect tombstones (or op-log history) that an offline client might still need to merge against. If a member has been offline since log_seq 100 and you've compacted everything before log_seq 500, their queued ops may reference items whose tombstones are gone, and a delete/re-add race can resolve wrongly. Bound how far behind you support (an offline window) and keep enough history to merge clients within it.

Tradeoffs & bottlenecks

  • Conflict-resolution granularity. Item-level resolution (per-field LWW, or a small CRDT) is the right altitude — whole-list LWW loses edits, and character-level OT is overkill. Getting the granularity right is the central judgment call this question tests.
  • Per-field LWW vs a small CRDT. LWW is simplest and fine for most fields; the small CRDT (LWW-register + add-wins set) adds provable convergence and correct delete/re-add handling for modest extra state. Either is defensible; whole-list LWW is not.
  • Order key vs integer positions. Fractional/lexicographic order keys make a move one write and let concurrent moves merge; dense integers rewrite many rows and collide. Order keys can grow and occasionally need rebalancing.
  • Pub/sub fan-out vs central registry. Pub/sub keyed by list id is simple and cheap because collaborator counts are tiny. A huge-fan-out system (a giant chat group) would weigh a central routing registry instead — here, don't over-build.
  • Optimistic local apply vs server authority. Applying changes locally makes the UI instant, but the client must reconcile when the server's merged order differs. This is the source of most client-side complexity, and the reason op ids and idempotent replay matter.
  • Offline window vs compaction. Supporting long offline edits forces you to retain tombstones and op history far back, fighting compaction. Bounding the supported offline window keeps garbage collection effective.

Extensions if asked

If the interviewer wants to push further, add only the extension that changes the design discussion (these are standalone topics; there's no dedicated guide for each yet):

  • Rich task metadata. Due dates, priorities, labels, assignees, subtasks. Mostly these are just more fields on an item, each an LWW-register; subtasks add a nesting/tree structure and a second order key per level.
  • Notifications and reminders. "Task due soon" or "Bob assigned you a task" — a separate scheduling/notification subsystem, delivered via push, orthogonal to the sync engine.
  • Permissions beyond membership. Per-item visibility, read-only members, sharing links with roles — enforced on the sync channel (a read-only member's ops are rejected).
  • Undo / history. The op log already records every change, so per-user undo is "apply the inverse of your last op"; showing an activity feed is reading the op log filtered by author.
  • Very large or heavily-shared lists. A list with thousands of items or hundreds of members pushes you toward paginating the initial load and toward the fan-out mitigations a large chat group uses (fan-out on read for offline members, a per-list channel above a size threshold).

What interviewers look for & common mistakes

What interviewers usually reward:

  • Recognizing the granularity. Item-level conflict resolution is enough; character-level OT is unnecessary, and whole-list LWW is wrong. Naming why a to-do list is simpler than Google Docs is the key insight.
  • A concrete conflict-resolution mechanism — per-field last-writer-wins with a deterministic tie-break, or a small CRDT (LWW-register per field + add-wins/OR-Set for membership) — with the delete-vs-edit race and idempotent op ids handled.
  • Real-time sync over WebSockets with pub/sub-per-list fan-out so members on different servers all receive each change, plus presence and reconnect catch-up (replay ops since last log_seq).
  • Reordering with a fractional/lexicographic order key, not dense integer positions — one write per move, concurrent moves merge.
  • Persistence as op log + materialized list, and offline editing that queues ops locally and replays them idempotently through the same merge on reconnect.
  • Optimistic local apply (read-your-writes for the actor) with eventual consistency across members.

Before you finish, do a quick mistake check:

  • Did you reject whole-list last-write-wins and explain that it silently drops concurrent edits?
  • Did you resolve conflicts per item/field — and not reach for character-level OT, which this problem doesn't need?
  • Did you name a deterministic tie-break (author id, not just wall-clock time) so replicas converge?
  • Did you handle the delete-vs-edit race with tombstones / an add-wins set, and make op application idempotent by op id?
  • Did you use WebSockets with pub/sub keyed by list id for cross-server fan-out, instead of polling?
  • Did you use a fractional order key for reordering, not dense integer positions?
  • Did you persist an op log and support catch-up on reconnect by replaying ops since the client's last log_seq?
  • Did you handle offline edits by queuing and idempotently replaying them through the same merge?

Practice this live

Run this exact question with our AI voice interviewer and get feedback.

Start the interview →