Data flow & caching design
Offset vs cursor pagination
?offset=40&limit=20 means "skip 40 rows". That is a positional window into a list that keeps moving, and on any actively written list the position lies twice:
GET /feed?offset=0&limit=10 → posts 1–10
(3 new posts land at the top)
GET /feed?offset=10&limit=10 → rows 11–20 of the NEW list = old posts 8–17
→ 8, 9, 10 rendered twice
Inserts above the window produce duplicates. Deletes produce silently skipped items, a bug no user ever reports. There is a server cost too: the database reads offset + limit rows to return limit, so page 500 is 500× the work of page 1.
A cursor anchors the window to an item instead of a position: "give me 20 posts after this one." The token is opaque to the client (typically base64 of the sort values) and requires a stable, unique sort key. created_at alone collides, so the key is (created_at, id). The server fetches limit + 1 rows; the extra row's existence sets has_more, and its key becomes next_cursor:
{ "items": [...], "next_cursor": "eyJjIjoxNzA5LCJpZCI6InA4NyJ9", "has_more": true }
The cursor's honest price: no jump-to-page, no cheap total count, cursors die if the sort changes, and the backend needs an index on the sort key. So the mapping follows the UI. Infinite scroll and feeds want cursors; a numbered admin table over slow-changing data is fine on offset. ❌ "Dedupe by id on the client" is a worthwhile seatbelt, but it only masks duplicates; the skipped-items half of the bug is invisible client-side. One more consequence people miss: when an accumulated infinite list refetches, you replay pages from the first one, and cursors captured before mutations can't be trusted.
When to normalize the client cache
Nested API responses rot in the client cache. The same post lives inside ['feed'] and inside ['post', 42]; a like mutation updates one copy, and the list and detail views now disagree. The underlying disease is update fan-out across duplicated entities.
Two cache shapes, one trade-off:
- Cache-per-query (what query caches do): each response stored whole under its key. Duplication is real, but reconciliation is dumb and reliable: invalidate the affected keys, refetch. Zero bookkeeping code.
- Normalized by id (Apollo, Redux entities): one record per entity, every view joins by id, one write updates all views. You pay in id-join indirection, hand-written cache-update logic, and manual garbage collection.
Default to cache-per-query plus invalidation; it is the cheapest thing that is correct. Normalize when many views mutate the same entities and invalidation-refetch gets too chatty: chat apps, editors, dashboards over shared records. ❌ Both absolutisms fail. "Always normalize" buys bookkeeping you may never need, and "query caches made normalization obsolete" ignores the fan-out case they solve by refetching, which has a network bill. Tooling mechanics (query keys, invalidateQueries) live in the react-state-management module.
How optimistic UI updates work
Updating state before the server answers is one third of the design. The full contract:
- Apply the predicted result locally.
- Rollback plan: keep what you overwrote, restore it on failure, and decide the failure UX up front (revert plus a toast, or an undo affordance).
- Reconcile: the server's answer is authoritative; on settle, adopt it or refetch.
What qualifies for optimism: reversible operations with high expected success whose result the client can compute. Likes, toggles, renames. What doesn't: payments, sends, anything with server-computed outcomes.
The half everyone forgets is the server contract. Optimistic UIs retry, and users double-tap:
POST /posts/42/toggle-like ❌ two deliveries cancel out: state flips back
PUT /posts/42/like {liked:true} ✅ idempotent: duplicates converge
Mutations must be idempotent: send the target state, or attach an idempotency key the server dedupes on. Debouncing the button is UX polish; it cannot protect against a network-level retry. ❌ "The UI already shows the right state, so the response doesn't matter." The response is the only thing that can tell you your guess was wrong.
Handling out-of-order responses
Nothing guarantees replies come back in request order. The canonical casualty is per-keystroke search: the request for rea resolves after the one for react and paints last, so the UI shows stale results for a query nobody is asking anymore.
Two guards, often combined:
let ctrl;
async function search(q) {
ctrl?.abort(); // kill the previous question
ctrl = new AbortController();
const res = await fetch(`/search?q=${q}`, { signal: ctrl.signal });
render(await res.json());
}
- Abort the previous request. Aborting frees the connection, and an aborted fetch can't render (filter
AbortErrorfrom real failures). - Key the response. Stamp each request, and before rendering check that the stamp still matches the current input; drop mismatches. Query caches enforce the same idea structurally: a response is stored under the key it was requested for, so a late reply can never land under the current key.
❌ "Debounce fixes it." Debounce cuts request volume; two in-flight requests can still finish out of order. ❌ "Await each request before the next." You get correct ordering by serialization and pay a full round trip of latency per keystroke; the concurrency is deleted, not managed. The invariant interviewers probe: a response may be applied only if it answers the question the UI is currently asking.
Concurrent writes: choose who loses
A blind PUT of the whole record is last-write-wins: two users edit, and the slower save silently erases the faster one. LWW is a legitimate choice for presence flags, cursors, and ephemeral prefs, but it has to be a choice, with a named victim. When data loss isn't acceptable, make staleness detectable:
PUT /articles/7
If-Match: "v18"
→ 200, ETag: "v19" # you edited what you thought you edited
→ 412 Precondition Failed # someone got there first: refetch, merge, retry
This is compare-and-set spelled in HTTP (conditional-request mechanics live in the http-caching module); a version column in the payload is the same idea without the header. The escalation ladder runs LWW → version check + reject (the client must handle the 412: reload-and-reapply, or show a merge prompt) → field-level merge (two users touching different fields shouldn't conflict at all) → CRDT/OT for genuinely collaborative surfaces. Pick per data type, and say the conflict UX out loud; a 412 with no plan is an error toast.
Offline queues and mutation replay
Once mutations can queue (offline-first, flaky mobile), the queue is a log of operations, and log-think applies:
- Every entry carries an idempotency key: replays after a crash, or after an ack that never arrived, must not double-apply.
- Order matters within an entity: create-then-rename replayed as rename-then-create is a 404. Preserve per-entity order; cross-entity ops can go parallel.
- Collapse before replay: like + unlike on the same post is a no-op, so squash pairs and keep only the latest set-state per field. This is why queued mutations should be state assignments rather than toggles: assignments collapse, while toggles must be counted.
- Replay hits conflicts: the world moved while you were offline, so replayed writes need the same version checks as live ones, plus a policy for the 412 (drop, re-apply on fresh data, or surface to the user).
The UI meanwhile renders cache plus pending queue overlaid, which is the optimistic contract from the middle notes stretched over minutes instead of milliseconds.
Live updates meet in-flight mutations
Add a WebSocket and your cache has three writers: user mutations (optimistic), refetches, and pushed events. The cache itself is a last-write-wins register, and whoever writes last is what the user sees, so convergence needs ordering discipline:
- Echo suppression: your own mutation comes back as a pushed event. Tag mutations with a client-generated id, have the server echo it, and skip events you originated; otherwise the echo can clobber a newer optimistic state, or double-insert the item you optimistically added.
- Version-gate the writes: if entities carry versions, every writer follows one rule: apply only if
incoming.version > cached.version. Stale refetch responses and out-of-order events drop out naturally; this is the request-keying invariant generalized to all sources. - In-flight mutation vs incoming event on the same entity: either buffer events for entities with pending mutations and apply them on settle, or apply the event and re-assert the optimistic diff on top. Buffering is easier to reason about; pick one and be consistent.
❌ "The socket makes cache staleness go away." It makes write ordering the problem instead, and it adds reconnect gaps you must heal with a refetch (missed events don't retransmit unless the protocol resumes from a sequence number).
Design the API with the backend
The endpoint is not a given. The response shape is a frontend design decision made through the backend (the BFF idea, with or without a literal BFF layer):
- Lists return
{ items, next_cursor, has_more }, shaped for the scroll, not for the database. - Mutations return the updated entity (new version, server-computed fields like
like_count), so the client reconciles without a follow-up fetch. - Expensive totals become their own cheap endpoint instead of taxing every page.
- Embed vs reference is decided together with your normalization plan: embedded author objects are convenient until many views mutate authors; at that point ids plus a separate query stop the fan-out.
Structuring the system design round
The frontend system-design round rewards structure, and failure modes are the differentiator. A shape that works: requirements (who reads, who writes, how live, offline?) → data model (entities, ownership: server truth vs client cache vs URL) → flows/API (pagination contract, mutation contracts, push channel) → failure modes. Raise the failures before the interviewer does (double-submit, out-of-order responses, concurrent edit, offline replay, reconnect gap), each mapped to the guard above. Attach the trade-off to the decision rather than naming tools: "cursor costs us jump-to-page; this UI never jumps." An infinite-scroll feed with likes exercises the whole file end to end: the pagination protocol, the mutation contracts, and three-writer reconciliation.
beta
The interviewer part is in the works.
The diagnostic, personal maps, and AI mock interviews are being finished right now. The notes stay free either way. Leave an email and you'll get the first-cohort invite, plus a month of Pro when it opens.