Skip to content

The finished design, decision by decision

How to design a Reactive Database (Live Queries)

Not the one correct diagram, but a design you can defend under these constraints: the finished architecture, then every stage's question with the reasoning that answers it, the tradeoffs it accepts, and where another engineer could land differently.

Live queries: screens that update in real time

The short answer

7 parts, each with one job. The map below shows how requests and data move between them; the stages after it explain why each part is there.

Web and mobile apps
Subscribe to queries, run mutations, render results.
Sync servers
Hold each client's WebSocket and its subscriptions; push new results tagged with the moment they are true at.
Function runners
Run queries and mutations against a snapshot and record each one's read set: the index ranges it scanned.
Versioned store
Append-only log of committed writes with indexes that can be read as of any recent timestamp.
Committer
Checks a mutation's read set against writes committed since it started; assigns commit timestamps in order.
Subscription tracker
Holds the read sets of all live queries and matches each commit's writes against them.
Result cache
Results keyed by query, arguments and timestamp, so identical subscriptions share one run.
123456789CLIENTWeb andmobile appsSERVICESync serversSERVICEFunction runnersDATABASEVersioned storeSERVICECommitterSERVICESubscriptiontrackerCACHEResult cache

Select a component to see what it is responsible for and which state it owns.

  1. 1Web and mobile apps → Sync servers: Subscribe and mutate
  2. 2Sync servers → Function runners: Run a function
  3. 3Function runners → Versioned store: Read at a snapshot
  4. 4Function runners → Committer: Writes plus read set
  5. 5Committer → Versioned store: Append in timestamp order
  6. 6Versioned store → Subscription tracker: Committed writes, in order
  7. 7Subscription tracker → Sync servers: These subscriptions changed
  8. 8Sync servers → Web and mobile apps: New results
  9. 9Function runners → Result cache: Reuse identical runs
  • Request / response
  • Asynchronous
  • Server push

Why does this design work?

Queries and mutations run as functions against a versioned store at a snapshot timestamp, and the runtime records each one's read set: the index ranges it scanned, including empty gaps. A subscription tracker holds the read sets of all live queries and matches each commit's writes against them in commit order, so only queries whose answer can have changed are re-run, and results are pushed over the client's WebSocket tagged with their timestamp. Clients apply updates only when all their subscriptions agree on a timestamp, so screens never mix moments.

The same read sets make mutations serializable: the committer accepts a mutation only if nothing it read changed after its snapshot, and otherwise it runs again. Identical subscriptions share one run through a result cache, and bursts of invalidations collapse into one run for the newest timestamp. Reconnecting clients replay mutations with IDs, then re-subscribe at one timestamp.

Invariants, and where they are enforced

  • Every committed write that can change a live result causes that result to be recomputed.

    Queries record the index ranges they scanned, including empty gaps; the tracker matches every commit, in order, against those ranges. Enforced by Function runners, Subscription tracker.

  • A client never shows results from two different moments at once.

    Every result carries the timestamp it was computed at; the client applies a set of updates only when all its subscriptions have reached the same timestamp. Enforced by Sync servers, Web and mobile apps.

  • Mutations behave as if they ran one at a time.

    Optimistic concurrency control: a mutation commits only if nothing in its read set changed since its snapshot, and is re-run otherwise. Enforced by Committer, Function runners.

What does it rely on?

  • Queries read through indexes, so their read sets are a few ranges rather than whole tables.
  • Contention on any single range is low enough that optimistic retries are rare.
  • The store keeps enough recent versions to validate mutations and run queries at a snapshot.
  • Mutations are idempotent by client-generated ID.

What tradeoffs does it make?

ChoiceGainsCosts
Recorded read setsExact invalidation without developer effort.Memory and matching work for every live query.
Optimistic concurrencySerializable mutations with no locks.Retries under contention; long mutations may starve.
Timestamped resultsConsistent screens.Requires a multiversion store and client-side buffering.

What are the reasonable alternatives?

Polling with short intervals
Better when few users, rare changes, and a delay of seconds is acceptable.
Change data capture plus targeted re-fetch
Better when keeping an existing database matters more than exact, consistent updates.
Local-first sync of raw data to the client
Better when offline use matters and each user's data is small enough to hold on the device.

When does it stop working?

  • Queries scan whole tables, so every write invalidates them.
  • Many writers contend for one range, so mutations retry endlessly.
  • Results depend on the viewer, so identical subscriptions cannot share a run.

Every stage, decided and explained

Spoilers, for the whole investigation: each stage's question and its answer, the reasoning behind it, and the tradeoffs it accepts. If you have not worked through the stages yet, you may want to do that first.

Work through the stages

Stage 1 of 9 · Model

What polling actually costs

Use the numbers in the scenario: 40,000 people online, five queries per screen, a poll every five seconds, 2,000 mutations a second.

What you need to know first

Polling and push put cost in different places:

  • Polling costs (viewers × queries per screen ÷ interval), whether anything changed or not.
  • Push costs (writes × queries each write affects), plus sending each new result to whoever watches it.

Which is cheaper depends on how often data changes compared with how often it's viewed. See Server push: polling, long polling, SSE, WebSockets.

40,000 people online, 5 queries per screen, polling every 5 seconds. How many query runs a second?

About 40,000 per second.

40,000 × 5 ÷ 5 = 40,000 runs a second, even if nothing changed.

To cut the delay to one second, polling every second. How many runs a second?

About 200,000 per second.

40,000 × 5 ÷ 1 = 200,000 a second: five times the load for a fifth of the delay. Polling is a dial between staleness and waste.

When is push dramatically cheaper than polling?

When data changes rarely relative to how often it's viewed, and the server can tell exactly which queries a write affects

Then most of polling's work re-reads unchanged data, and push only does work for real changes.

What the stage asks

Which statements follow?

  1. Holds

    Polling costs about 40,000 query runs a second, whether or not anything changed.

    40,000 people × 5 queries ÷ 5 seconds = 40,000 runs a second. The cost follows the number of viewers, not the amount of change.

  2. Holds

    Most of those polls return exactly what the client already has.

    2,000 writes a second, each touching one board or task, change a small fraction of the 200,000 live results in any five-second window. Most polls re-read data nobody touched.

  3. Holds

    Polling every second instead would fix the delay without changing the architecture, at five times the load.

    It would cut the delay to a second and cost 200,000 runs a second. It is a dial between staleness and waste, which is why polling stops scaling once both matter.

  4. Depends

    With push, server work depends only on how many writes there are, not on how many people are watching.

    Each write has to be matched against live subscriptions, and each affected query re-run and sent to its viewers. If a write affects one small board, that is cheap. If it affects a query thousands of people watch, sending the result is proportional to the audience, unless identical subscriptions share one run. That case comes later.

The reasoning

  1. Polling costs viewers × queries ÷ interval, whether or not anything changed.
  2. Push costs writes × affected queries, plus delivery to viewers.
  3. Push wins when change is rare relative to viewing, if affected queries can be found exactly.

Polling makes cost proportional to viewers × queries ÷ interval. Push makes it proportional to writes × the queries each write affects, plus sending results to the people watching them. When change is rare relative to viewing, push wins by orders of magnitude, but only if the server can tell cheaply and exactly which queries a write affects. That is the rest of this investigation.

Stage 2 of 9 · Decide

Which queries did that write change?

A query is a function the app's developers wrote. It might read a board's columns through one index, then each column's tasks through another, and filter them in code. The server cannot read the function and know in advance what it depends on.

What you need to know first

To push only what changed, the server must answer, for each write: which live queries could this change? Queries are code the developers wrote, so the server can't read their dependencies off the source.

But it can watch them run. Whatever index ranges a query scans while running are exactly the data its answer depends on. That record is the query's read set.

200,000 live queries, 2,000 writes a second. Re-running every query after every write: how many runs a second?

About 400 million runs per second.

200,000 × 2,000 = 400 million runs a second. Correct, and impossible.

Re-run every query that read a table whenever that table changes. Why isn't that enough here?

The tasks table changes 2,000 times a second, so nearly every query re-runs nearly constantly.

Table-level tracking is too coarse when every query reads the same busy table. It's polling, only faster.

Developer-declared tags ("this query depends on board:17") can be precise too, but a forgotten tag fails silently: a screen that never updates. A read set recorded by the database can't forget.

What the stage asks

How does the server decide which subscriptions to re-run after a write?

  1. Flawed

    Re-run every live query after every write

    200,000 live queries × 2,000 writes a second is 400 million runs a second. It is correct and impossible.

  2. Defensible

    Remember which tables each query read; re-run every query on a table when that table changes

    Simple and safe. But the tasks table changes 2,000 times a second, so nearly every query is re-run nearly all the time: back to polling, only faster. It works for small apps with few writes.

  3. Sound

    While a query runs, record the index ranges it scans; after each commit, re-run only queries whose ranges contain a written key

    The record is exact and automatic: whatever the function did, the database saw which index ranges it read. A task moving on board 17 matches only the queries that read board 17's range. Everything else costs nothing.

  4. Defensible

    Have developers tag each query with invalidation keys, and tag each mutation with the keys it touches

    Precise when the tags are right, and many caches work this way. But a forgotten tag fails silently: a screen that just never updates. The database already knows what each query read, so asking humans to restate it adds a way to be wrong.

What a strong answer covers

  • Records what each query actually read, at run time, not what a developer declares.
  • Only queries whose read ranges contain a written key are re-run.
  • Cannot miss a dependency, because the record comes from the reads themselves.
  • Names the cost: storing read sets for every live query, and matching every write against them.Supporting

The reasoning

  1. A query's dependencies are whatever it read: record the index ranges it scans.
  2. Re-run only queries whose recorded ranges contain a written key.
  3. Recorded read sets can't miss a dependency the way hand-written tags can.

The idea that makes live queries practical is that a query's dependencies are whatever it read. Run it once, write down the index ranges it touched (its read set), and you know exactly which future writes can change its answer. The tracker keeps every live query's read set and checks each commit against them in order.

It turns invalidation from a question about code ("what does this function depend on?") into a question about data ("does this key fall in that range?"), which the database can answer quickly with interval lookups. See Caching for why invalidation is usually the hard part.

Tradeoffs

ChoiceGainsCosts
Re-run per tableTrivial to build.Re-runs almost everything on a busy table.
Developer-declared tagsPrecise, works with any store.A missing tag is a silent bug.
Recorded read setsPrecise and automatic.Memory for read sets; the store must expose index ranges.

Stage 3 of 9 · Break it

The task that never appeared

The first version of the read-set recorder is below. Select the lines that explain the bug.

What you need to know first

A query depends on what it didn't find as much as on what it found. "Tasks in Backlog" depends on there being no other Backlog tasks; a new one changes the answer.

Rows that appear inside a range a query already scanned are called phantoms.

The recorder saves the keys of rows a scan returned. A new task is inserted into the scanned column. Does the query re-run?

No: the new key wasn't among the returned rows, so no recorded key matches it.

Edits change keys that were returned, so they match. Inserts add keys that weren't, so they slip through.

The fix is to record the range scanned (from, to), including its empty parts, and check each written key with an interval test: does it fall inside any recorded range?

A move changes two keys: the old one leaves a range, the new one enters another. Both must be checked.

What the stage asks

Select the faulty lines.

Codetypescriptreadset.ts: recording what a query depends on
  1. 1// Called by the query runtime for every index scan a query performs.
  2. 2function recordScan(readSet: ReadSet, index: string, from: Key, to: Key, rows: Row[]) {
  3. 3 for (const row of rows) readSet.add({ index, key: row.key });

    It records the rows the scan returned, not the range it scanned. An insert creates a key that did not exist when the query ran, so it can never match a recorded row.

  4. 4}
  5. 5
  6. 6// Called for every committed write.
  7. 7function affects(readSet: ReadSet, write: Write): boolean {
  8. 8 return readSet.has({ index: write.index, key: write.key });

    An exact-key lookup cannot detect a new key landing inside a range the query scanned. The check must ask whether the written key falls within any recorded range.

  9. 9}

What the fix has to do

  • Edits change keys that were returned, so they match; inserts add keys that were not, so they do not.
  • Record the scanned range (from, to), including the empty parts of it.
  • Match each written key against the recorded ranges with an interval check.
  • Notes that a delete or a move out of the range must also match, by its old key.Supporting

The reasoning

  1. Record the scanned range, not the rows returned, or inserts (phantoms) are missed.
  2. Match written keys against recorded ranges with an interval check.
  3. A move changes two keys; check both the old and the new.

A query's answer depends on what it did not find as much as on what it found. "Tasks in Backlog, ordered by position" depends on the absence of other Backlog tasks; inserting one changes the answer. Databases call these new rows phantoms, and the cure here is the same as in serializable transactions: lock, or in this case record, the range, not the rows.

Writes that move a row change two keys: the old one leaves a range and the new one enters another. Both must be checked.

Stage 4 of 9 · Decide

Twelve tasks, eleven cards

The board and the count are separate subscriptions. Each is re-run and pushed as soon as its own run finishes.

What you need to know first

When a screen shows several queries, each one is true at some moment. If they're re-run and pushed independently, the screen can show the board at one moment and the count at another: twelve tasks counted, eleven shown.

A multiversion store keeps recent versions of data, so it can answer "as of timestamp T". That makes the moment explicit.

Every result is tagged with the timestamp it was computed at. What rule should the client follow?

Apply updates only once all of a screen's subscriptions have reached the same timestamp, then apply them together

The screen moves from one consistent moment to the next and never shows a mix.

How can the server make clients wait less under that rule?

After each commit, re-run all of a client's affected queries at the same timestamp and send them together. The client then rarely holds a result back waiting for another.

What the stage asks

How do you stop a screen showing two different moments?

  1. Defensible

    Merge everything a screen shows into one big query

    One query is internally consistent, so the problem disappears. But every small change re-runs and resends the whole screen, and components can no longer subscribe to what they need independently.

  2. Sound

    Run every query at a snapshot timestamp, tag each result with it, and have the client apply updates only when all its subscriptions have reached the same timestamp

    Each result says the moment it is true at. The client holds back the count at timestamp 1050 until the board's 1050 result arrives, then applies both at once. Screens move from one consistent moment to the next.

  3. Flawed

    Delay all pushes by 200 ms so related updates usually arrive together

    Usually is the problem: under load the two runs can still straddle the delay, and every update now arrives 200 ms late even when nothing else changed.

  4. Defensible

    Accept it; the screen catches up within a few hundred milliseconds

    Many apps live with this. It is a real trade-off, not a mistake, if nothing on screen is used to make decisions. Here users act on what they see, and one requirement forbids it.

What a strong answer covers

  • Every query runs against a snapshot at a known timestamp.
  • Results are tagged with that timestamp.
  • The client applies updates only when all its subscriptions reach the same timestamp.
  • Requires a store that can read as of a recent timestamp (multiversion).Supporting

The reasoning

  1. Run every query at a snapshot timestamp and tag each result with it.
  2. Clients apply updates only when all subscriptions reach the same timestamp.
  3. Consistency across queries needs a store that can read as of a recent time.

Consistency across queries is a property of time, so the fix is to make time explicit. A versioned store can answer "as of timestamp T", every result is labelled with its T, and the client's rule is simple: never show a mix of Ts. The sync server can help by re-running all of a client's affected queries at the same timestamp after each commit, so the client rarely has to wait.

It is the same reason databases give a transaction one snapshot instead of letting each statement see the latest data. See Transactions.

Where another engineer could land differently

If screens are dashboards nobody acts on, a brief mismatch may be an acceptable price for a simpler client.

Stage 5 of 9 · Break it

Seven tasks in a column of five

The move mutation counts the tasks in the target column, refuses if the count is at the limit, and otherwise updates the task's column. Each mutation runs in a transaction at snapshot isolation: it sees the database as of the moment it started.

What you need to know first

Under snapshot isolation, each transaction reads the database as of when it started, and only conflicts if two transactions write the same row.

A rule that spans several rows (at most five tasks in a column) isn't protected. Two transactions can each read the rows, each decide the rule holds, and each write a different row. Both commit. This is write skew. See Concurrency control.

The column has 4 tasks, limit 5. Three moves start at once, each counting 4 in its snapshot. Each updates a different task's column. What happens?

All three commit: no two wrote the same row, so snapshot isolation sees no conflict. The column ends with 7 tasks, two over the limit.

Optimistic concurrency control checks reads as well as writes: at commit, if anything the transaction read has been written since its snapshot, abort and re-run it. The first move commits; the other two find their read range changed, re-run, count five, and refuse.

The same read sets that power subscriptions make this check possible, which gives serializable mutations without locks.

What's the cost of validating reads at commit?

A heavily contended range causes repeated aborts and retries.

If many mutations read and write the same range at once, most lose validation and re-run. Fine for occasional contention, costly for constant contention.

What the stage asks

How do you make the limit hold?

  1. Flawed

    Check the count in the client before sending the move

    Every client saw four tasks. Checks on stale copies are exactly what failed; they belong on the server, inside the transaction.

  2. Defensible

    Lock the column's row at the start of the mutation, so moves into a column take turns

    Correct, if every mutation that can affect the rule remembers to take the same lock in the same order. It is easy to forget one, and locks held across a function's whole run hurt throughput and can deadlock.

  3. Sound

    Record each mutation's read set; at commit, if anything it read has been written since its snapshot, abort and run it again

    Each mutation read the range 'tasks in In Progress'. The first to commit changes that range, so the other two fail validation, re-run against the new state, see five tasks and refuse. No locks, nothing for developers to remember, and the read sets are the ones already recorded for subscriptions.

  4. Defensible

    Add a database constraint for the limit

    Ideal where it exists, but most databases cannot express 'at most N rows with this value' as a constraint, and every new rule would need its own.

What a strong answer covers

  • Each saw four tasks in its own snapshot and wrote a different row, so snapshot isolation let all three commit (write skew).
  • Validating the read set at commit detects that what a mutation read has changed.
  • The losing mutations re-run on fresh data and reach the right answer.
  • Notes the cost: a heavily contended range causes repeated retries.Supporting

The reasoning

  1. Snapshot isolation doesn't protect rules spanning rows: concurrent writes to different rows cause write skew.
  2. Validate a mutation's read set at commit; re-run it if anything it read has changed.
  3. Optimistic control avoids locks but retries under heavy contention.

The three moves wrote different rows, so nothing collided at the row level. The rule spans rows, and snapshot isolation does not protect rules that span rows. This is write skew.

Optimistic concurrency control fixes it by checking reads, not just writes: a mutation commits only if the world it read is still the world. With read sets already recorded for subscriptions, the same machinery gives serializable mutations: developers can write code as if mutations ran one at a time. The cost appears under contention: if many mutations fight over one range, most of them retry. See Concurrency control.

Tradeoffs

ChoiceGainsCosts
Pessimistic locksNo wasted work under contention.Every code path must lock correctly; deadlocks; held for the whole run.
Optimistic validationNo locks to forget; serializable by default.Retries when many writers contend for one range.

Stage 6 of 9 · Decide

Write the commit check

The committer receives a mutation's snapshot timestamp, its read set (index ranges) and its writes. It keeps the writes of recent commits in memory. Write the function that decides whether the mutation may commit.

What you need to know first

Validation asks one question about a time window: did anything committed between my snapshot and now touch what I read?

A single committer that assigns increasing timestamps in order makes that window well-defined, and lets the check and the append of the new commit happen as one step.

A mutation's snapshot is timestamp 100. Which recent commits must it be checked against?

Only commits with timestamps after 100

Commits at or before 100 were already visible in its snapshot, so they can't have changed what it read.

The committer keeps only the last few seconds of commits in memory. A mutation arrives with a snapshot older than anything it still holds. What should it do?

Refuse and force a retry. It can no longer tell whether anything since that snapshot touched the read set, so committing would be a guess.

What the stage asks

Implement tryCommit.

Reference implementation

const inRange = (r: Range, index: string, key: string) => r.index === index && key >= r.from && key < r.to;

function touches(r: Range, w: Write) {
  return inRange(r, w.index, w.key) || (w.oldKey !== undefined && inRange(r, w.index, w.oldKey));
}

export function tryCommit(snapshotTs: number, readSet: Range[], writes: Write[]) {
  // This function is the only writer of lastTs and recent, so check-then-append is atomic.
  const oldestHeld = recent[0]?.ts ?? lastTs;
  if (snapshotTs < oldestHeld - 1) return { ok: false as const }; // too old to validate: re-run

  for (const commit of recent) {
    if (commit.ts <= snapshotTs) continue;
    for (const w of commit.writes) {
      if (readSet.some((r) => touches(r, w))) return { ok: false as const };
    }
  }

  const ts = ++lastTs;
  recent.push({ ts, writes });
  return { ok: true as const, ts };
}
  • Only commits after the snapshot matter: anything earlier was already visible to the mutation.
  • A move writes a new key and removes an old one, and either can change a range someone read.
  • If the history needed to validate has been pruned, the safe answer is "retry", never "commit".
  • Real systems index the recent writes so this is not a scan, and use the same range matching to find affected subscriptions.

What a strong answer covers

  • Checks only commits after the mutation's snapshot timestamp.
  • Detects a conflict when a written key, or a moved row's old key, falls inside a read range.
  • Assigns a new, increasing timestamp and records the commit only when there is no conflict.
  • Makes the check and the append one atomic step (a single committer, or a lock).Supporting
  • Refuses (forces a retry) when the snapshot is older than the commits still held.Supporting

The reasoning

  1. Check only commits after the mutation's snapshot against its read ranges.
  2. Assign timestamps and record the commit in the same atomic step as the check.
  3. Refuse snapshots older than the commit history you still hold.

Validation is a question about a time window: did anything committed between my snapshot and now touch what I read? A single committer that assigns timestamps in order makes that window well defined, and makes the check and the append one step. It is also the natural place to publish each commit, in order, to the subscription tracker.

Stage 7 of 9 · Change it

One board, a hundred thousand viewers

Every comment changes the board's result. The board query returns the same thing for everyone in the company.

What you need to know first

100,000 viewers watch one board; 5 comments a second change it. Re-running the board query per viewer per change: how many runs a second?

About 500,000 runs per second.

5 × 100,000 = 500,000 runs a second, for a result that's identical for everyone.

A result can be shared if the query, its arguments and its timestamp are the same and it doesn't depend on who's asking. Run it once per change and send the result to every subscriber. That's Request coalescing.

If changes arrive faster than runs finish, skip the intermediate versions: run once for the latest timestamp.

Which query can't simply share one result across all viewers?

"My assigned tasks", or anything filtered by the viewer's permissions

Its result depends on who's asking, so each viewer has a different answer.

Computing once doesn't remove delivery: sending a result to 100,000 sockets is Fan-out on write and fan-out on read, spread across the sync servers that hold those connections.

What the stage asks

How do you keep this from overwhelming the function runners?

  1. Flawed

    Re-run the board query for each viewer after every comment

    Five comments a second × 100,000 viewers is half a million runs a second for one result that is identical for everyone.

  2. Sound

    Run each distinct (query, arguments, timestamp) once and send that result to every subscriber; when invalidations arrive faster than runs finish, run once for the latest timestamp

    One run per change serves all 100,000 viewers. If comments arrive while the previous run is still going, intermediate versions are skipped and the next run covers them all. The cost becomes sending, not computing.

  3. Defensible

    Switch this one query back to polling every ten seconds

    It caps the load at a known rate, at the price of the delay users complained about. A reasonable emergency switch, not a design.

  4. Flawed

    Add more function runners

    Capacity spent recomputing the same answer 100,000 times. It also shifts the bottleneck to the store, which every run reads.

What a strong answer covers

  • Results can be shared when the query, its arguments and its timestamp are the same and it does not depend on who is asking.
  • Bursts of invalidations collapse into one run for the newest timestamp.
  • What remains is fan-out of the result to 100,000 connections, spread across sync servers.
  • Queries that depend on the viewer (permissions, 'my tasks') cannot be shared directly.Supporting

The reasoning

  1. Run each distinct (query, arguments, timestamp) once and share the result with every subscriber.
  2. Collapse bursts of invalidations into one run for the newest timestamp.
  3. Viewer-specific queries can't be shared directly, and delivery fan-out remains.

A hot query is a Request coalescing problem: many people asking the same question at the same moment should cause one computation. Cache results by (function, arguments, timestamp), and the hundred-thousandth viewer costs a lookup. Coalescing invalidations means work follows how often the answer can be observed to change, not how often it changes.

What cannot be coalesced is delivery. Sending a result to 100,000 sockets is Fan-out on write and fan-out on read, spread across the sync servers that hold those sockets. Sending only what changed, rather than the whole board, makes each send small.

Stage 8 of 9 · Break it

Back from the tunnel

The connection is back. Mutations carry a client-generated ID. The server can run queries at its current timestamp.

What you need to know first

An optimistic UI shows a change immediately, before the server confirms it, and queues it while offline. On reconnect, three rules keep that safe:

  • Idempotent mutations: each carries a client-generated ID, so the server can skip any it already committed.
  • One timestamp: re-subscribed queries are answered at a single moment, including the replayed mutations.
  • Server results win: the client drops optimistic changes that the server's results now include.

The connection dropped just after a mutation was sent. The client doesn't know if it committed, and resends it. What prevents it applying twice?

Its client-generated ID: the server skips an ID it already committed

The same idempotency idea as payments, applied to app mutations. See Idempotency.

What the stage asks

Put the reconnection steps in order.

In this order

  1. 1Reconnect and re-authenticate the WebSocket
  2. 2Resend the queued mutations in order, each with its ID; the server skips any ID it already committed
  3. 3Re-subscribe to every query the screen needs
  4. 4The server runs them at one timestamp that includes the replayed mutations, and sends the results tagged with it
  5. 5The client drops its optimistic changes that are now included and shows the new results all at once

Mutations go first, so the fresh results already include them. Their IDs make the replay safe if a mutation reached the server just before the connection dropped and only its acknowledgement was lost; see Idempotency. All subscriptions are then answered at one timestamp, so the screen jumps from its old moment to a single new one instead of flickering through partial states. Only then does the client remove its optimistic changes, because the server's results now contain the real outcome, which may differ: one of those moves might have hit the work-in-progress limit.

The reasoning

  1. Give every mutation a client ID so replays after reconnect are skipped if already committed.
  2. Re-subscribe and answer all queries at one timestamp that includes the replayed mutations.
  3. Server results win: drop optimistic changes once they're included.

Reconnection is where optimistic UIs earn or lose trust. The rules are the ones from earlier stages, applied in order: idempotent mutations so replays are safe, one timestamp so the screen stays consistent, and server results win so the client converges on what actually happened.

Stage 9 of 9 · Defend it

Defend building it into the database

Your interviewer: "This is a lot of machinery. Why not keep Postgres, add a cache, and use change data capture or LISTEN/NOTIFY to tell clients to re-fetch?"

What you need to know first

Change data capture (CDC) and LISTEN/NOTIFY tell you a row changed. They don't tell you which queries that change affects. Mapping one to the other needs either read sets or hand-written rules, which is the problem this design solved.

A strong defence names the properties the simpler route lacks, not just the mechanism yours has.

When is "Postgres + cache + notify clients to re-fetch" the right call?

For apps with few writes, coarse invalidation, or users who tolerate brief inconsistency

Then the missing properties (exactness, one moment per screen, serializable rules) cost little, and the simpler route costs far less to build.

What the stage asks

Defend the design, concede what the simpler route gets right, and say when you would choose it.

Reference answer

What a re-fetch signal cannot tell you. Change data capture says "task 812 changed". It does not say which live queries that affects. You either re-fetch broadly (back to polling's waste) or write the mapping by hand (back to silent bugs). Recording each query's read ranges answers the question exactly, including inserts that land in a range.

Consistency comes for free only with a snapshot. If each subscription re-fetches on its own, a screen can show two moments. Running affected queries at one timestamp, and tagging results with it, fixes that; a plain cache plus notifications does not.

The same records protect writes. Read sets also let the committer validate mutations, so rules like a work-in-progress limit hold under concurrency without locks.

What I concede. This is a database-sized project. For an app with modest write rates, a few kinds of screen and users who tolerate a second of delay, Postgres plus a notification channel that triggers targeted re-fetches is much cheaper to build and run. I would choose the integrated design when many screens must be live, writes are frequent, and consistency on screen matters, or I would buy it rather than build it.

What a strong answer covers

  • A change notification says a row changed, not which queries it affects; mapping that needs read sets or hand-written rules.
  • Re-fetching queries separately brings back the mixed-moment problem unless they share a snapshot.
  • The same read sets give serializable mutations; the alternative still needs its own answer to write skew.
  • Concedes that the simpler route is right for apps with few writes, coarse invalidation or tolerant users, and costs far less to build.

The reasoning

  1. A change notification says a row changed, not which queries it affects.
  2. Name the properties the simpler route lacks: exactness, one moment per screen, serializable rules.
  3. Concede that re-fetch on notify is right for apps with few writes or tolerant users.

Strong defences name the property the simpler design lacks, not just the mechanism the complex one has. Here those properties are exactness (no missed or wasted re-runs), consistency (one moment per screen) and serializability (rules hold under concurrency), and all three come from one idea: knowing precisely what each function read.

How you did

Now try it as an interview question

  • “Design Firebase, or a real-time database.”
  • “How would you make a dashboard update live without polling?”
  • “Design the sync layer for a collaborative task tracker like Linear or Trello.”
  • “Two users break a rule at the same moment. How do you prevent it?”

The interview mode mixes stages from this and other investigations with concept recall and questions about your own projects.

Back to the last stage