System Design NotesRohit’s interview study guide
Chapter 14

Frontend Case Studies

Eight frontend system design rounds worked end to end (autocomplete, news feed, carousel, chat, collaborative editor, product page and checkout, data table, design system), each with the logic at its heart run in Node, the numbers calculated, and the trade-offs you say out loud.

8 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#Design an autocomplete component

Added

"Design an autocomplete" is the most common frontend system design prompt because it looks like one text box and hides four real problems. As the user types, you must not send a request for every keystroke (load), answers that come back out of order must not overwrite newer ones (races), the same query must not be fetched twice (caching), and the whole thing must work from the keyboard and with a screen reader (accessibility). A candidate who builds the box and the dropdown has done the easy 20%; the round is about those four.

A mental model that covers the first three: you're asking a slow, unreliable librarian for books while you're still deciding what you want. You don't shout every letter as you think of it; you wait until you pause (debounce). If two answers come back, you only care about the one to your latest question, however late or early it arrives (latest request wins). And you write every answer down, so asking the same thing again costs nothing (a per-query cache).

Run the round in the RADIO order. Here's where this design lands in each step, so you can see the whole answer before the detail:

StepWhat you decide for autocomplete
RequirementsServer-backed suggestions, keyboard and screen-reader support, works on phones, reusable for other data sources
ArchitectureA headless controller (debounce, race guard, cache) under thin ComboboxInput and SuggestionList components
Data modelquery, status, items, activeIndex, open, plus a Map cache keyed by normalised query
InterfaceProps fetchSuggestions(q, { signal }), onSelect(item), renderItem; server GET /suggest?q=&limit=
OptimisationsDebounce vs throttle, AbortController, prefix reuse, IME composition, safe highlighting, CDN caching

The heart of the design is a few lines of control logic. This model runs it against a fake clock and a fake server whose answers take different times, so the out-of-order case actually happens:

autocomplete.mjs
// A fake clock: timers only fire when advance() moves time forward.
let now = 0;
const timers = [];
const later = (fn, ms) => { const t = { at: now + ms, fn }; timers.push(t); return t; };
const cancel = (t) => timers.includes(t) && timers.splice(timers.indexOf(t), 1);
async function advance(to) {
  for (let t; (t = timers.sort((a, b) => a.at - b.at)[0]) && t.at <= to; ) {
    timers.shift(); now = t.at; t.fn();
    await new Promise((r) => setImmediate(r)); // let promise callbacks run
  }
  now = to;
}

// Fake server: each query has its own latency, so answers can arrive out of order.
const latency = { rea: 600, react: 100 };
let calls = 0;
const fetchSuggestions = (q) => {
  calls++; console.log(`${now}ms  request "${q}"`);
  return new Promise((ok) => later(() => ok([`${q} one`, `${q} two`]), latency[q] ?? 150));
};

// The component's logic: debounce, a per-query cache, and latest-request-wins.
function createAutocomplete({ wait = 250, render }) {
  const cache = new Map();
  let timer, latest = 0;
  async function run(q) {
    const id = ++latest;                      // this request's ticket
    if (cache.has(q)) return render(q, cache.get(q), "cache");
    const list = await fetchSuggestions(q);
    cache.set(q, list);                       // cache every answer, even a late one
    if (id !== latest) return console.log(`${now}ms  drop stale "${q}"`);
    render(q, list, "network");
  }
  return { input(q) { cancel(timer); timer = later(() => run(q), wait); } };
}

const ac = createAutocomplete({
  render: (q, list, from) => console.log(`${now}ms  show "${q}" from ${from}: ${list.join(", ")}`),
});

// The user types "rea", pauses, types "ct", then deletes back to "rea".
const keys = [[0, "r"], [120, "re"], [240, "rea"], [600, "reac"], [700, "react"], [1500, "reac"], [1600, "rea"]];
for (const [at, q] of keys) { await advance(at); ac.input(q); }
await advance(3000);
console.log(`${keys.length} keystrokes, ${calls} requests`);
Result
490ms  request "rea"
950ms  request "react"
1050ms  show "react" from network: react one, react two
1090ms  drop stale "rea"
1850ms  show "rea" from cache: rea one, rea two
7 keystrokes, 2 requests
What’s happening
  1. The fake clock keeps timers in a list and only runs them when advance(to) moves time forward, so the run is exact and repeatable. After each timer it waits one setImmediate, which lets the await inside run() continue, the same way a real event loop would between a network response and your code.
  2. Keystrokes at 0, 120 and 240 ms each call input(), which cancels the previous timer and starts a new 250 ms one. Only the last survives, so the first request goes out at 240 + 250 = 490 ms for "rea". Three keystrokes, one request.
  3. "rea" is slow (600 ms, back at 1090). Meanwhile the user types "reac" and "react"; the debounce fires at 700 + 250 = 950 and asks for "react", which is fast and returns at 1050. Its ticket id is 2, equal to latest, so it's shown.
  4. At 1090 the slow "rea" answer finally arrives. Its ticket is 1 but latest is 2, so it's dropped instead of overwriting the newer list. Without that one comparison the user would be looking at "react" in the box and suggestions for "rea" underneath. The answer is still written to the cache: it's correct data, just not for now.
  5. The user deletes back to "rea". The debounce fires at 1850, finds "rea" in the cache and renders it with no request. Total: 7 keystrokes, 2 requests.
  6. If you changed the order of ++latest and the cache check (bumping the ticket only for network requests), a cache hit would not invalidate a request still in flight, and that older request could land on top of the cached list. Every new query must take a ticket, whatever path it takes.
AdvancedAutocomplete requirements and questions to ask

Requirements

Ask before you draw. The answers change the design more than any library choice.

  • Where do suggestions come from? A fixed list of 250 countries ships with the page and is filtered in memory, no network at all. Millions of products or users need a server with a prefix index (the backend side is Design search autocomplete). Say which one you're designing; usually it's the server-backed one.
  • What is a suggestion? Plain text, or rich rows (avatar, category, price)? Grouped by type ("People", "Products")? Rich rows change the item model and the rendering cost.
  • What happens on select? Fill the box and search, navigate to a page, or add a chip (a multi-select)? This decides the onSelect contract.
  • Empty state: recent searches or trending items when the box is focused but empty?
  • Minimum characters before searching (often 1 for names, 2–3 for full-text).
  • Reuse: one search box, or a component the whole company will use for any data source? Reuse pushes you to a headless core with the data source passed in.

Non-functional requirements, written on the board:

  • Feels instant. A cached answer appears with no network; a network answer within a few hundred milliseconds of the pause. Typing itself must never lag, which means rendering the list can't block the input (INP of 200 ms or less at the 75th percentile is Google's "good" line, web.dev, checked October 2026).
  • Kind to the server. One request per pause, not per keystroke.
  • Correct under races. The list always matches the text in the box.
  • Accessible. Follows the WAI-ARIA Authoring Practices combobox pattern: arrow keys move through options, Enter selects, Escape closes, screen readers hear the active option.
  • Works on phones and with input methods (Japanese, Chinese and Korean typists compose characters before committing them).
AdvancedAutocomplete load estimates

Estimates

Frontend estimates are about what the client causes: requests per user action, bytes per response and work per keystroke. The typing speed comes from the largest public typing study, 136 million keystrokes from 168,000 volunteers (Dhakal et al., CHI 2018), which measured an average of 52 words per minute. The DAU, searches per user and pause count are assumptions to state out loud:

autocomplete-estimates.mjs
// Assumptions (stated in the text): typing speed from the 136M-keystroke study, the rest are design inputs.
const wpm = 52;                 // average typist (Dhakal et al., CHI 2018)
const charsPerWord = 5;         // the standard WPM convention
const queryLength = 12;         // characters in a typical search
const searchesPerUserPerDay = 6;
const dau = 20_000_000;
const peakFactor = 3;           // busiest hour vs the daily average
const debounceMs = 250;
const pausesPerQuery = 2;       // a user stops typing about twice per query

const msPerKey = 60_000 / (wpm * charsPerWord);
const perQueryNoDebounce = queryLength;          // one request per keystroke
const perQueryDebounced = pausesPerQuery;        // one request per pause longer than debounceMs
const qps = (perQuery) => Math.round((dau * searchesPerUserPerDay * perQuery) / 86_400 * peakFactor);

console.log(`average gap between keys: ${msPerKey.toFixed(0)} ms (debounce ${debounceMs} ms waits for a pause)`);
console.log(`peak QPS, request per keystroke: ${qps(perQueryNoDebounce).toLocaleString("en")}`);
console.log(`peak QPS, debounced:             ${qps(perQueryDebounced).toLocaleString("en")}`);
console.log(`saved by debouncing: ${(100 * (1 - perQueryDebounced / perQueryNoDebounce)).toFixed(0)}%`);
const payload = 8 * 60;         // 8 suggestions × ~60 bytes of JSON each
console.log(`suggestion payload: ~${payload} bytes, ~${(payload * qps(perQueryDebounced) * 8 / 1e9).toFixed(2)} Gbit/s at peak`);
Result
average gap between keys: 231 ms (debounce 250 ms waits for a pause)
peak QPS, request per keystroke: 50,000
peak QPS, debounced:             8,333
saved by debouncing: 83%
suggestion payload: ~480 bytes, ~0.03 Gbit/s at peak
What’s happening
  1. 52 words per minute at the standard 5 characters per word is 260 characters a minute, so an average typist presses a key every 60 000 / 260 = 231 ms. (The study also measured the gap directly: 238 ms on average, the same conclusion.) A 250 ms debounce is just above that gap: it waits through ordinary typing and fires on a real pause. That's why 250–300 ms is the usual setting; treat it as a rule of thumb you tune with real data.
  2. Without debouncing, a 12-character query is 12 requests. At 20 million users doing 6 searches a day, with a 3× peak, that's 20M × 6 × 12 / 86 400 × 3 = 50 000 requests per second.
  3. With debouncing, each query fires roughly once per pause. With two pauses per query the same traffic is 8,333 requests per second, 83% less. That's a backend capacity number you got from a frontend decision, which is exactly the kind of link interviewers like.
  4. Responses are tiny (8 suggestions, about 480 bytes), so bandwidth is not the constraint (0.03 Gbit/s at peak). Request count and server CPU are, which is why the debounce and the cache matter more than compression here.
AdvancedAutocomplete component architecture

Architecture

Split the component into a headless controller that owns the behaviour and thin presentational pieces that own the markup. The controller is where debounce, the race guard and the cache live, and it can be tested in Node like the model above. The same controller can then sit under a search box, a user picker or a tag input. This is the container/presentational split from Component architecture applied to one widget.

<Autocomplete> ComboboxInput role="combobox" SuggestionList role="listbox" ≤ 8 options useAutocomplete headless: logic, no markup debounce 250 ms latest request wins cache: query → items API client fetch + AbortSignal CDN caches popular prefixes Suggest service top-k per prefix keys items q
The behaviour lives in a headless controller; the components only render state. The server side is a cached prefix lookup.

Walk the picture left to right when you present it: the input emits keystrokes, the controller decides whether and when to ask, the API client makes a cancellable request, and the CDN answers popular prefixes without reaching the service. Items flow back into the controller (cache first, then state) and the list renders them.

AdvancedAutocomplete state and cache shape

Data model

The state is small, and every field has a reason to exist:

autocomplete-state.js
const state = {
  query: "rea",            // what's in the box (the controlled input value)
  status: "success",       // "idle" | "loading" | "success" | "error"
  items: [                 // what the list shows, always for `query`
    { id: "t:react", label: "react", type: "topic", match: [0, 3] },
  ],
  activeIndex: -1,         // highlighted option, -1 = none (drives aria-activedescendant)
  open: true,              // is the listbox shown (drives aria-expanded)
};
// Outside the rendered state, because changing it shouldn't re-render anything:
const cache = new Map();   // normalised query → { items, fetchedAt }, LRU-capped at ~100 entries
let latest = 0;            // the race-guard ticket
What’s happening
  1. query and items are kept separate on purpose: the list may still be showing the previous query's items while a new request is in flight (showing old results dimmed is calmer than flashing an empty list on every keystroke).
  2. status is one field with four values rather than isLoading and isError booleans, so impossible combinations (loading and error) can't be represented.
  3. Each item has a stable id, used for the option's DOM id and as the React key, and a match range so the highlight is computed by the server or the controller, not by re-parsing HTML.
  4. cache and latest live outside rendered state (a ref in React, a plain field in an Angular service). They change on every request, and re-rendering for them would cost work and show nothing.
  5. The cache key is the normalised query: trimmed, lower-cased, with the locale and any filters included (en|rea), otherwise two users' or two languages' answers would collide.
AdvancedAutocomplete props and suggest API

Interface

The component's API, for the teams that will use it:

Autocomplete props
type Props<T> = {
  fetchSuggestions: (query: string, opts: { signal: AbortSignal }) => Promise<T[]>;
  getKey: (item: T) => string;
  getLabel: (item: T) => string;            // the text put in the box on select
  renderItem?: (item: T, state: { active: boolean; query: string }) => ReactNode;
  onSelect: (item: T) => void;
  minChars?: number;                         // default 1
  debounceMs?: number;                       // default 250
  maxItems?: number;                         // default 8
  label: string;                             // the visible <label>; required, not optional
};
What’s happening
  1. fetchSuggestions is passed in, which is what makes the component reusable: the user picker passes one that calls /users/search, the product search one that calls /suggest. It receives an AbortSignal so the component can cancel it.
  2. getKey and getLabel keep the component generic over the item type T instead of assuming { id, name }.
  3. renderItem is the escape hatch for rich rows; the component still owns the option element, its id, role and aria-selected, so a custom renderer can't break accessibility.
  4. label is required. An autocomplete without a programmatic label is announced as "edit text" with no name, and making the prop optional guarantees someone will forget it.
  5. This sketch was not compiled (it's an excerpt in React style); the same contract works as Angular input() and output() members.

The server API it needs:

Suggest API
GET /api/suggest?q=rea&limit=8&locale=en
200 OK
Cache-Control: public, max-age=60
{ "query": "rea", "items": [ { "id": "t:react", "label": "react", "type": "topic", "match": [0, 3] } ] }

Two details are worth saying. The response echoes the query, which gives the client a second way to drop stale answers (if response.query isn't the current query, ignore it). And it's a cacheable GET with a short public max-age, so a CDN can answer the most popular prefixes without reaching the service at all. Personalised suggestions (recent searches) can't be shared like that; fetch them separately with private caching, or merge them on the client.

AdvancedDeep dive: cancelling instead of ignoring

The ticket in the main model ignores a stale answer after it arrives. The other approach is to cancel the old request as soon as a new one starts, with an AbortController. This model uses a fake fetch that honours the signal exactly as fetch() does:

abort-previous.mjs
// The other fix for out-of-order answers: cancel the previous request when a new one starts.
// fakeFetch honours an AbortSignal the way fetch() does: it rejects with an AbortError.
let served = 0;
function fakeFetch(q, { signal }, ms) {
  return new Promise((resolve, reject) => {
    const t = setTimeout(() => { served++; resolve(`results for "${q}"`); }, ms);
    signal.addEventListener("abort", () => { clearTimeout(t); reject(signal.reason); }, { once: true });
  });
}

let controller = null;
async function search(q, ms) {
  controller?.abort(new DOMException(`superseded by "${q}"`, "AbortError"));
  controller = new AbortController();
  try {
    console.log("show", await fakeFetch(q, { signal: controller.signal }, ms));
  } catch (e) {
    if (e.name === "AbortError") console.log(`cancel "${q}" (${e.message})`); // expected: not an error to show
    else throw e;
  }
}

const done = [search("rea", 60), search("reac", 40), search("react", 10)];
await Promise.all(done);
console.log(`responses the client waited for: ${served} of 3`);
Result
cancel "rea" (superseded by "reac")
cancel "reac" (superseded by "react")
show results for "react"
responses the client waited for: 1 of 3
What’s happening
  1. search("rea") creates a controller and starts a 60 ms request. Before it finishes, search("reac") calls controller.abort(...) on the old one, which fires its abort listener synchronously: the timer is cleared and the promise rejects with the AbortError passed as the reason.
  2. The catch sees e.name === "AbortError" and treats it as normal control flow. Showing "Something went wrong" for a request the component cancelled on purpose is a classic bug; every other error is re-thrown.
  3. "reac" is cancelled the same way by "react", which completes and is shown. Only 1 of the 3 responses was ever waited for.
  4. The difference from the ticket approach: aborting frees the browser's connection and stops downloading the body, but the server has usually already done the work. Abort saves the client, not the backend; the debounce is what saves the backend.
  5. In production use both: abort the previous request, and keep the ticket check, because an answer can already be resolved and queued as a microtask when the abort happens. Belt and braces costs two lines.
AdvancedDeep dive: debounce, throttle and the first keystroke

Debounce waits for a pause, which is right for most search boxes. Its weakness is that a fast, continuous typist sees nothing until they stop. Throttle (at most one request per interval, say every 300 ms, while typing continues) shows results that update during typing, at the cost of more requests. Some products use both: a leading-edge request on the first character (so the list appears at once), then debounce. Say which you pick and why: for a global search box with millions of users, debounce, because the backend cost dominates; for an in-app picker over a small dataset, throttle or no delay at all, because there's no backend to protect.

Two cheap tricks make it feel faster without more requests:

  • Serve the cache immediately, without waiting for the debounce. In the model, the cached "rea" still waited 250 ms; skipping the timer on a cache hit makes backspacing feel instant.
  • Filter what you already have. If the cached list for "rea" was complete (fewer items than limit), the answer for "reac" is a subset of it: filter locally and skip the request.
AdvancedDeep dive: keyboard and ARIA combobox

The WAI-ARIA Authoring Practices "list autocomplete" combobox example (W3C APG, checked October 2026) defines the contract: DOM focus stays in the text box the whole time, the highlighted option is pointed to with aria-activedescendant, aria-expanded says whether the list is shown, and aria-controls links the box to the list. Down Arrow moves to the next option and wraps, Enter accepts the option, Escape closes the list. Writing the keyboard logic as a pure reducer makes it testable without a browser:

combobox-keys.mjs
// The combobox's keyboard behaviour as a pure reducer: state in, key in, new state out.
// active is the highlighted option's index, or -1 when focus is only in the text box.
function onKey(state, key) {
  const n = state.options.length;
  switch (key) {
    case "ArrowDown":
      if (!state.open) return { ...state, open: n > 0, active: n > 0 ? 0 : -1 };
      return { ...state, active: (state.active + 1) % n };          // wraps last → first
    case "ArrowUp":
      if (!state.open) return { ...state, open: n > 0, active: n - 1 };
      return { ...state, active: (state.active - 1 + n) % n };      // wraps first → last
    case "Enter":
      if (state.active < 0) return { ...state, submitted: state.value };
      return { ...state, value: state.options[state.active], open: false, active: -1 };
    case "Escape":
      return state.open ? { ...state, open: false, active: -1 } : { ...state, value: "" };
    default:
      return state;
  }
}

// What the DOM attributes become for a state (what a screen reader is told).
const aria = (s) => ({
  "aria-expanded": String(s.open),
  "aria-activedescendant": s.active >= 0 ? `opt-${s.active}` : "",
});

let s = { value: "rea", options: ["react", "react native", "readme"], open: true, active: -1 };
for (const key of ["ArrowDown", "ArrowDown", "ArrowUp", "ArrowUp", "Enter", "Escape"]) {
  s = onKey(s, key);
  console.log(key.padEnd(9), `value="${s.value}"`.padEnd(16), JSON.stringify(aria(s)));
}
Result
ArrowDown value="rea"      {"aria-expanded":"true","aria-activedescendant":"opt-0"}
ArrowDown value="rea"      {"aria-expanded":"true","aria-activedescendant":"opt-1"}
ArrowUp   value="rea"      {"aria-expanded":"true","aria-activedescendant":"opt-0"}
ArrowUp   value="rea"      {"aria-expanded":"true","aria-activedescendant":"opt-2"}
Enter     value="readme"   {"aria-expanded":"false","aria-activedescendant":""}
Escape    value=""         {"aria-expanded":"false","aria-activedescendant":""}
What’s happening
  1. The state starts open with three options and nothing active (active: -1), the moment after suggestions arrive. Focus is in the input, and aria-activedescendant is empty.
  2. Down Arrow moves active -1 → 0 → 1; aria turns that into aria-activedescendant="opt-0", then "opt-1". A screen reader announces the option that the attribute points to, although DOM focus never left the input; that's what lets the user keep typing.
  3. Up Arrow goes 1 → 0, then from 0 wraps to the last option ((0 - 1 + 3) % 3 = 2). Wrapping is what the APG example does; some designs stop at the ends instead, and either is fine if it's consistent.
  4. Enter with an active option copies its label into the box ("readme"), closes the list and clears the active option: aria-expanded becomes "false". Enter with nothing active would submit what was typed instead.
  5. Escape on a closed list clears the box, the optional second Escape behaviour in the APG pattern; on an open list it only closes it. Each key has exactly one meaning per state, which is why a reducer (state, key) → state fits so well.

The markup that state produces, as a static live example (the frame runs no scripts, so this is the "Down Arrow pressed twice" moment, rendered):

Combobox markup for one state
CSS
.ac { width: 260px; font: 14px system-ui, sans-serif; }
.ac label { display: block; margin-bottom: 4px; font-weight: 600; }
.ac input { box-sizing: border-box; width: 100%; height: 34px; padding: 0 10px; border: 1px solid #94a3b8; border-radius: 8px; }
.ac ul { list-style: none; margin: 4px 0 0; padding: 4px; border: 1px solid #cbd5e1; border-radius: 8px; }
.ac li { height: 30px; line-height: 30px; padding: 0 8px; border-radius: 6px; }
.ac li[aria-selected="true"] { background: #dbeafe; color: #1e3a8a; }
.ac mark { background: none; font-weight: 700; color: inherit; }
HTML
<div class="ac">
  <label for="q">Search</label>
  <input id="q" type="text" value="rea" role="combobox" aria-autocomplete="list"
         aria-expanded="true" aria-controls="q-list" aria-activedescendant="opt-1">
  <ul id="q-list" role="listbox" aria-label="Suggestions">
    <li id="opt-0" role="option" aria-selected="false"><mark>rea</mark>ct</li>
    <li id="opt-1" role="option" aria-selected="true"><mark>rea</mark>ct native</li>
    <li id="opt-2" role="option" aria-selected="false"><mark>rea</mark>dme</li>
  </ul>
</div>
Resultlive, in your browser
What’s happening
  1. Chrome's accessibility tree (read by the test) reports the input as role combobox with the name Search, taken from the <label for="q">. That name is what a screen reader says on focus.
  2. The <ul> is a listbox and each <li> an option. The option's accessible name is its text, "react native"; the <mark> around the matched part doesn't split it, so highlighting costs nothing in accessibility.
  3. aria-activedescendant="opt-1" resolves to the option with aria-selected="true", and the CSS keys the highlight off that same attribute (rgb(219, 234, 254) on it, transparent on the others). Styling from the ARIA state means the visual highlight and what the screen reader hears can never disagree.
  4. In the real component, the controller writes these attributes on every state change, and also scrolls the active option into view, which the APG example does too. A polite live region announcing "3 suggestions" when results arrive tells screen-reader users the list exists at all.
AdvancedAutocomplete trade-offs at a glance

Trade-offs and what to say

DecisionChooseBecauseThe other option wins when
When to requestDebounce 250 msOne request per pause; 83% fewer requests in the estimateThrottle or no delay: small or local data, live-updating results matter more
Stale answersAbort + ticket checkAbort saves the client; the ticket closes the last raceTicket only: the data source isn't abortable (a WebSocket, a worker)
CachingIn-memory Map, LRU ~100, short TTLBackspacing and retyping are freePersist to storage: recent searches across sessions
FilteringServer prefix indexDataset too big to shipClient filter: a few thousand items or fewer, shipped with the page
Focus modelaria-activedescendantFocus stays in the input, typing never stopsRoving focus into the list: rarely, for complex grid popups
Two answers, one box

The user types ab, pauses (a request for ab goes out, slow), then types c and pauses (a request for abc goes out, fast). Neither is cached. The controller uses the ticket check but no abort. What does the list show after both answers arrive, and what's in the cache?

Show answer

The list shows abc's suggestions; the cache holds both ab and abc.

What’s happening
  1. The ab request takes ticket 1; latest is 1.
  2. The abc request takes ticket 2; latest is 2. It's fast, comes back first, passes id === latest and is rendered.
  3. The slow ab answer arrives. It's stored in the cache (the code caches before the check), then fails 1 === 2 and is dropped, so the screen keeps abc.
  4. If the user now deletes the c, ab is served from the cache with no request. Caching late answers is free value; showing them would be a bug.

#Design a news feed UI

Added

The backend half of a news feed is about fan-out and ranking (Design a news feed). The frontend half is about an endless list that stays correct, fast and calm: pages of posts must join without duplicates or gaps, the page must not hold 18,000 DOM nodes after a long scroll, new posts must not shove the list out from under the reader's thumb, and a tap on "like" must respond instantly even though the server takes a few hundred milliseconds.

The mental model: the reader is standing on a conveyor belt. They move at their own pace. New items queue at the entrance and only join when the reader says so (the "N new posts" banner). Items far behind are packed away (virtualised), but the space they took is remembered so the belt doesn't jump. And every item is stored once, by id, however many times it shows up on the belt.

StepWhat you decide for the feed UI
RequirementsInfinite scroll, new-post banner, like/comment/share, images and video, works on slow phones, accessible as a feed
ArchitectureFeedPage → NewPostsBanner + virtualised FeedList of PostCard; a normalised feed store; REST for pages, SSE for new-post notices
Data modelorder: id[], byId: Map, cursor, pending: Post[]; posts and authors normalised
InterfaceGET /feed?cursor=&limit=, GET /feed/events (SSE), PUT /posts/:id/like; PostCard props are an id and callbacks
OptimisationsCursor pagination + dedup, windowing with measured heights, prefetch threshold, optimistic likes, reserved media space

The store is the heart of it. This model merges pages by cursor, skips posts it already has, and holds new posts back behind a banner:

feed-store.mjs
// The feed's client store: ids in display order, posts by id, the next-page cursor,
// and new posts held back until the reader asks for them.
function createFeed() {
  const s = { order: [], byId: new Map(), cursor: null, pending: [] };
  const has = (id) => s.byId.has(id);
  return {
    // A page from GET /feed?cursor=… is appended; anything already shown is skipped.
    appendPage({ posts, nextCursor }) {
      let added = 0;
      for (const p of posts) {
        if (has(p.id)) { s.byId.set(p.id, p); continue; }   // refresh the data, keep the position
        s.byId.set(p.id, p); s.order.push(p.id); added++;
      }
      s.cursor = nextCursor;
      return added;
    },
    // A push from the server: don't move the list under the reader's thumb.
    newPostsArrived(posts) {
      for (const p of posts) if (!has(p.id) && !s.pending.some((q) => q.id === p.id)) s.pending.push(p);
    },
    banner: () => (s.pending.length ? `${s.pending.length} new post${s.pending.length > 1 ? "s" : ""}` : null),
    // Tap the banner: newest first, on top, then scroll to the top.
    showNew() {
      const fresh = s.pending.sort((a, b) => b.createdAt - a.createdAt);
      for (const p of fresh) s.byId.set(p.id, p);
      s.order.unshift(...fresh.map((p) => p.id));
      s.pending = [];
    },
    view: () => s.order.join(" "),
    cursor: () => s.cursor,
  };
}

const post = (id, createdAt) => ({ id, createdAt, text: `post ${id}` });
const feed = createFeed();

console.log("added", feed.appendPage({ posts: [post("p9", 9), post("p8", 8), post("p7", 7)], nextCursor: "c7" }));
// The server re-sends p7 at the start of page 2 (a ranking change moved it): it must not show twice.
console.log("added", feed.appendPage({ posts: [post("p7", 7), post("p6", 6), post("p5", 5)], nextCursor: "c5" }));
console.log("view  ", feed.view(), "| cursor", feed.cursor());

feed.newPostsArrived([post("p10", 10)]);
feed.newPostsArrived([post("p11", 11), post("p10", 10)]);  // p10 again over a reconnect
console.log("banner", feed.banner(), "| view unchanged:", feed.view());
feed.showNew();
console.log("view  ", feed.view(), "| banner", feed.banner());
Result
added 3
added 2
view   p9 p8 p7 p6 p5 | cursor c5
banner 2 new posts | view unchanged: p9 p8 p7 p6 p5
view   p11 p10 p9 p8 p7 p6 p5 | banner null
What’s happening
  1. The store keeps ids in display order (order) separately from the posts themselves (byId). Display order is a list of strings; the data lives once in a Map. That's what lets has(id) be a constant-time check and lets an update to a post (a new like count) change one entry instead of searching the list.
  2. Page 1 adds p9 p8 p7 and remembers nextCursor: "c7". The next request is GET /feed?cursor=c7, which means "posts after p7", not "page 2". A cursor survives new posts arriving at the top, where an offset (?offset=3) would shift and skip or repeat posts.
  3. Page 2 starts with p7 again (rankings shift, or the server's cursor is inclusive). has("p7") is true, so its data is refreshed but it isn't added a second time: added 2, and the view is p9 p8 p7 p6 p5. Without that check you get the same post twice in a row, the most common feed bug users screenshot.
  4. New posts pushed by the server go into pending, not into order, so the list the user is reading doesn't move. p10 arrives twice (once normally, once replayed after a reconnect) and is kept once, so the banner honestly says 2 new posts.
  5. Tapping the banner sorts the pending posts newest first, puts them on top and clears pending: p11 p10 p9 …. The UI then scrolls to the top so the reader sees what they asked for.
  6. This is the same "single source of truth, normalised by id" idea as in State: server state, client state and URL state, applied to a list that only grows.
AdvancedFeed UI requirements and questions to ask

Requirements

Questions to ask first:

  • Which platform and which surfaces? Mobile web first (the hardest: small CPU, slow network) and desktop. Is there a separate post detail page, and do we return to the same scroll position from it?
  • Post types: text, images (one or several), video, links with previews, polls? Each type is a different card component and a different loading cost.
  • Interactions: like, comment, share, hide, report. Which must feel instant? Like does; report can show a spinner.
  • Freshness: must new posts appear live, or is "pull to refresh" enough? Live means a push channel and the banner.
  • Ordering: chronological or ranked? Ranked feeds make "where was I?" harder, because the server may reorder between pages; that's why dedup matters.

Non-functional requirements:

  • Smooth scrolling on a mid-range phone: no long tasks while scrolling, DOM size bounded.
  • Fast first screen: LCP of 2.5 s or less at the 75th percentile, Google's "good" threshold (web.dev, checked October 2026). The first page of posts is rendered on the server or streamed, not fetched after JavaScript boots.
  • No layout shift from images or inserted posts (CLS of 0.1 or less is "good").
  • Accessible: the WAI-ARIA APG feed pattern (checked October 2026): the container has role="feed", each post is an article with aria-posinset and aria-setsize (-1 when the total is unknown), the feed is marked aria-busy="true" while a page is being added, and Page Down / Page Up move between articles.
AdvancedFeed UI DOM and bytes estimates

Estimates

The numbers that drive a feed UI are DOM size, bytes per page and how far ahead to fetch. Card size, node counts and image weights are assumptions for a typical rich card on a phone:

feed-estimates.mjs
// Assumptions: a long session, a fairly rich post card, a phone viewport.
const postsScrolled = 300;       // a long scrolling session
const nodesPerPost = 60;         // avatar, name, text, image, action bar, counters…
const postHeight = 520;          // px, average card on a phone
const viewport = 800;            // px of visible list
const overscan = 2;              // extra cards rendered above and below
const pageSize = 10;
const jsonPerPost = 1.5 * 1024;  // bytes of post JSON (text, author, counts, image URLs)
const imagePerPost = 120 * 1024; // bytes, one image sized for the phone's width

const visible = Math.ceil(viewport / postHeight) + 1;   // a partly visible card at each edge
const mounted = visible + 2 * overscan;
console.log(`DOM nodes, everything mounted: ${(postsScrolled * nodesPerPost).toLocaleString("en")}`);
console.log(`DOM nodes, virtualised (${mounted} cards): ${(mounted * nodesPerPost).toLocaleString("en")}`);
console.log(`pages fetched: ${postsScrolled / pageSize}, JSON per page: ${(pageSize * jsonPerPost / 1024).toFixed(0)} KiB`);
console.log(`images if every scrolled post loads: ${(postsScrolled * imagePerPost / 1024 / 1024).toFixed(0)} MiB`);
const scrollPxPerSec = 1500;     // a fast fling
const cardsPerSec = scrollPxPerSec / postHeight;
const threshold = 5;             // start fetching page n+1 this many cards before the end
console.log(`a fast fling crosses ${cardsPerSec.toFixed(1)} cards/s; fetching ${threshold} cards early gives ${(threshold / cardsPerSec).toFixed(1)} s of head start`);
Result
DOM nodes, everything mounted: 18,000
DOM nodes, virtualised (7 cards): 420
pages fetched: 30, JSON per page: 15 KiB
images if every scrolled post loads: 35 MiB
a fast fling crosses 2.9 cards/s; fetching 5 cards early gives 1.7 s of head start
What’s happening
  1. 300 posts with about 60 DOM nodes each is 18,000 nodes if every card stays mounted. Lighthouse's DOM size check (the "Avoid an excessive DOM size" audit, folded into the "Optimize DOM size" insight in Lighthouse 13) warns above about 800 body nodes and errors above about 1,400 (Chrome for Developers documentation, checked October 2026). Style recalculation and layout get slower with every node, and that shows up as poor INP on taps.
  2. With windowing, only the cards in or near the viewport exist: ceil(800 / 520) + 1 = 3 visible plus 2 above and 2 below = 7 cards, 420 nodes, whatever the scroll length. See Infinite scroll and list virtualisation for the windowing maths.
  3. JSON is cheap (15 KiB per page of 10); images are not. 300 images at 120 KiB is 35 MiB, which is why images are lazy-loaded and sized for the device, and why cards far off-screen release their images when unmounted.
  4. A fast fling covers about 2.9 cards a second. Starting the next page request when the reader is 5 cards from the end gives 1.7 s of head start, comfortably more than a page request on a slow connection. A threshold of 1–2 cards would show a spinner on every fling.
AdvancedFeed UI architecture and channels

Architecture

<FeedPage> NewPostsBanner FeedList role="feed", virtualised PostCard article · LikeButton PostCard ~7 mounted at once Feed store normalised by id order: id[] byId: Map cursor pending: Post[] + like versions GET /feed?cursor= pages of 10 GET /feed/events SSE: new post ids PUT /posts/:id/like desired state, idempotent Feed service fan-out, ranking (ch. 11) tap
Components render from one normalised store; pages come in by cursor, new posts arrive as notices, likes go out as idempotent PUTs.

Three channels, each chosen for its job. Pages are plain cacheable GETs. New-post notices come over server-sent events: one-way, server to client, automatic reconnect built into EventSource, and enough because the client never needs to push on that channel. A WebSocket would work too but adds a protocol to maintain for no gain here. Writes (likes, comments) are ordinary HTTP requests, which keeps retries, auth and logging in the tools you already have.

The first page is rendered with the HTML (server-side rendering or streaming, see Rendering strategies for an app) and its data is embedded in the page so the client store starts full, without a second request.

AdvancedNormalised feed store model

Data model

feed-model.js
// Normalised client store
const store = {
  feed: { order: ["p9", "p8"], cursor: "c8", pending: [], status: "idle" }, // "idle" | "loading" | "error" | "end"
  posts: {
    p9: { id: "p9", authorId: "u7", text: "…", media: [{ url: "…", w: 1080, h: 1350, alt: "…" }],
          createdAt: "2026-10-24T09:12:00Z", likes: 41, likedByMe: false, comments: 3 },
  },
  users: { u7: { id: "u7", name: "Asha", avatar: "…" } },
};
// Per-card UI state that is NOT server data stays in the card: "show more" expanded, video playing.
// Measured card heights for the virtualiser: Map<postId, px>, kept outside rendered state.
What’s happening
  1. posts and users are separate tables keyed by id, as the server would store them. An author who appears on 20 cards is stored once, so changing their avatar or name updates every card.
  2. Media carries its intrinsic w and h. The card uses them to reserve the right box (aspect-ratio: 1080 / 1350) before the image downloads, which is what keeps CLS at zero.
  3. likes and likedByMe are server data with an optimistic overlay (next deep dive). Expanded/collapsed text is UI state and stays local to the card; putting it in the store would re-render the whole list on every "show more".
  4. status: "end" records that the server returned no nextCursor, so the list stops asking. Without it, a short feed requests an empty page on every scroll event.
AdvancedFeed API and component contracts

Interface

The server API:

Feed API
GET /api/feed?cursor=c8&limit=10
→ 200 { "posts": [...], "users": {...}, "nextCursor": "c18" }       nextCursor null at the end

GET /api/feed/events            (text/event-stream)
→ event: new-posts   data: {"ids":["p31","p32"]}                     ids only; the client fetches on tap

PUT /api/posts/p9/like          { "liked": true }
→ 200 { "liked": true, "likes": 45 }

The component contracts stay small: PostCard takes a postId and reads the post from the store, plus callbacks like onLike(postId) and onOpen(postId). Passing an id instead of the whole post object means a like on one card re-renders that card only. FeedList takes ids, a renderItem(id), estimateHeight and onNearEnd(); it knows nothing about posts, so the same list component serves comments and notifications.

Why notices carry ids only: if 50 posts arrive while the tab is in the background, you don't want to download 50 posts' worth of JSON and images for a banner the user might never tap. The banner needs a count; the posts are fetched when the user asks.

AdvancedDeep dive: optimistic likes

A like has to flip instantly, so the UI updates before the server answers, and has to be corrected if the server says no. The subtle part is rapid tapping, where answers to several requests overlap:

optimistic-like.mjs
// Optimistic like/unlike: the heart flips at once, the server catches up.
// Rapid taps send several requests; only the latest one may decide what the UI shows on failure.
const post = { id: "p1", liked: false, likes: 41 };
const sent = [];                                    // requests waiting for an answer
let version = 0;

function tap() {
  const prev = { liked: post.liked, likes: post.likes };
  post.liked = !post.liked;
  post.likes += post.liked ? 1 : -1;                // optimistic update
  const v = ++version;
  sent.push({ v, want: post.liked, prev });
  console.log(`tap ${v}: show ${post.liked ? "♥" : "♡"} ${post.likes}  → PUT /posts/p1/like {liked:${post.liked}}`);
}

function answer(v, ok, serverLikes) {
  const req = sent.find((r) => r.v === v);
  if (ok) {
    if (v === version) post.likes = serverLikes;    // latest answer: take the server's count
    console.log(`answer ${v}: ok${v === version ? `, count from server ${serverLikes}` : " (older, ignored)"}`);
  } else if (v === version) {
    Object.assign(post, req.prev);                  // latest intent failed: roll back to before it
    console.log(`answer ${v}: failed, roll back to ${post.liked ? "♥" : "♡"} ${post.likes}`);
  } else {
    console.log(`answer ${v}: failed but a newer tap superseded it, nothing to undo`);
  }
}

tap();                 // like
tap();                 // unlike
tap();                 // like again: the user's final intent is "liked"
answer(2, false);      // the unlike failed, but tap 3 already replaced it
answer(1, true, 42);
answer(3, true, 45);   // others liked it meanwhile: the server's number wins
console.log(`final: ${post.liked ? "♥" : "♡"} ${post.likes}`);
Result
tap 1: show ♥ 42  → PUT /posts/p1/like {liked:true}
tap 2: show ♡ 41  → PUT /posts/p1/like {liked:false}
tap 3: show ♥ 42  → PUT /posts/p1/like {liked:true}
answer 2: failed but a newer tap superseded it, nothing to undo
answer 1: ok (older, ignored)
answer 3: ok, count from server 45
final: ♥ 45
What’s happening
  1. Each tap flips liked, adjusts the count by ±1 and records what it overwrote (prev) under a new version. The heart changes immediately: ♥ 42, then ♡ 41, then ♥ 42.
  2. The request sends the desired state, PUT … {liked:true}, not "toggle". A toggle sent twice by a retry would undo itself; "set to liked" sent twice is still liked. That's what makes the request idempotent and safe to retry.
  3. Answer 2 (the unlike) fails, but tap 3 has already replaced it. Rolling back to tap 2's prev would show ♥ for the wrong reason and then fight with tap 3, so a failure only rolls back if it belongs to the latest version.
  4. Answer 1 succeeds but is older, so it's ignored. Answer 3 succeeds and is the latest, so the count is replaced with the server's 45: other people liked the post meanwhile, and the server's number wins over the client's guess.
  5. The final state is the user's last intent (♥) with the true count. React 19's useOptimistic and TanStack Query's onMutate / onError rollback implement the same idea; the versioning for overlapping requests is the part you still have to think about. One case this model leaves out: if the latest request fails after an earlier one succeeded, rolling back to its prev can disagree with the server, and PUTs can also reach the server out of order. Production clients either send one request per post at a time (queue the rest) or refetch the true state after any failure (see Data fetching and client caching).
AdvancedDeep dive: virtualisation with real cards

Windowing is easy when every row is 40 px. Feed cards aren't: a text post is 150 px, a photo post 600 px, and nobody knows a card's height until it's rendered with its fonts and images. The usual approach:

  • Estimate, then measure. Use an estimated height per post type for cards never seen, measure each card with a ResizeObserver once it's mounted, and store the measurement by post id. Positions are prefix sums of known-or-estimated heights.
  • Keep the scroll position stable when estimates are corrected. If a card above the viewport turns out 80 px taller than estimated, the content you're reading moves down 80 px. Adjust scrollTop by the difference (CSS overflow-anchor does this for normal flow, but virtual lists position cards absolutely, so the library has to do it).
  • Restore the position on Back. Save the first visible post id and its offset (not a pixel scrollTop, which depends on estimates) when the user opens a post, and restore to that id when they come back. Keeping the store in memory across the navigation (not refetching) is what makes this possible.
  • Trigger the next page with a sentinel. An IntersectionObserver on an element 5 cards from the end, with a rootMargin, fires onNearEnd(). It costs nothing while scrolling, unlike a scroll handler that measures on every event.

The card itself must reserve its media box before the image arrives. That part HTML and CSS can show:

Card that reserves its image space
CSS
.feed { max-width: 500px; margin: 0 auto; font: 14px system-ui, sans-serif; }
.banner { position: sticky; top: 0; z-index: 1; display: block; margin: 0 auto 8px; width: 150px; height: 32px; border: 0; border-radius: 16px; background: #1d4ed8; color: #fff; font: inherit; }
.card { border: 1px solid #cbd5e1; border-radius: 12px; padding: 12px; margin-bottom: 12px; background: #fff; }
.card h3 { margin: 0 0 8px; font-size: 14px; }
.media { aspect-ratio: 1080 / 1350; width: 100%; border-radius: 8px; background: linear-gradient(135deg, #e2e8f0, #cbd5e1); }
HTML
<div class="feed" role="feed" aria-busy="false" aria-label="Your feed">
  <button class="banner">2 new posts</button>
  <article class="card" aria-posinset="1" aria-setsize="-1" aria-labelledby="t1">
    <h3 id="t1">Asha shared a photo</h3>
    <div class="media" role="img" aria-label="Sunset over the harbour"></div>
  </article>
</div>
Resultlive, in your browser
What’s happening
  1. .media has no image in it yet, only aspect-ratio: 1080 / 1350 and width: 100%. The test measures its height as exactly width × 1350 / 1080 at both 360 px and 800 px viewports: the box is the right size before the image exists, so when the image loads nothing moves.
  2. At 800 px the feed is capped at 500px wide; at 360 px it's 336px (the frame's body has a 12 px margin each side). The ratio holds either way because it's relative to whatever width the card gets.
  3. The accessibility tree reports role="feed" on the container and article for the card, named "Asha shared a photo" through aria-labelledby. aria-setsize="-1" is the APG's way of saying "total unknown", which is the honest answer for an infinite feed.
  4. The banner is position: sticky; top: 0, so it stays visible while scrolling inside the feed without being taken out of the flow (it doesn't cover the first card when the list is at the top).
AdvancedDeep dive: when new posts arrive

You have three options, and the interview is mostly about why the banner wins:

OptionWhat happensProblem
Insert at the top immediatelyList grows above the readerContent jumps (layout shift), or with anchoring the new posts are invisible anyway
Poll and show on refresh onlyUser pulls to refreshNothing tells them there's something new
Count in a banner, insert on tap"3 new posts" appears; tap inserts and scrolls upOne more component; you need dedup between notices and pages

Use push (SSE) when the product wants liveness, and a 60-second poll when it doesn't justify a connection per user; the banner logic is the same either way, only the source of notices changes. When the tab is hidden (document.visibilityState === "hidden"), stop polling or close the stream and catch up with one request when it's visible again.

AdvancedFeed UI trade-offs at a glance

Trade-offs and what to say

DecisionChooseBecauseThe other option wins when
PaginationCursor + client dedupStable under inserts; dedup covers ranking shiftsOffset: a static, admin-style list where "page 7" must be addressable
Long listsWindowing with measured heights420 DOM nodes instead of 18,000Plain list + "load more": short feeds, SEO pages, simpler a11y
New postsBanner fed by SSE notices (ids only)No jumps, no wasted downloadsLive insert: chat-like streams where the newest item is the point
LikesOptimistic, versioned, PUT desired stateInstant, idempotent, correct under rapid tapsPessimistic: actions with real cost (purchase, delete account)
First pageServer-rendered with embedded dataLCP without waiting for JS and an API callClient-only: behind login and not performance-critical

#Design a chat app UI

Added

A chat client looks like a list and a text box, but it's really a small sync engine with a UI on top. A sent message has to appear instantly, before the server has seen it. The server, not the client, decides the real order, and its answer can arrive after another copy of the same message. The phone goes into a tunnel mid-conversation and the user keeps typing. The same account is open in three tabs and on a phone. The backend half (fan-out, storage, presence at scale) is Design a chat system; this is the half that runs in the browser.

The mental model: a letter with a reference number you write yourself. Before you post a letter, you stamp your own reference on it (the clientId). The post office files it and gives it an official number (the server seq). Whatever comes back to you later, the post office's receipt or a copy of the letter sent to everyone in the group, you match by your own reference, so nothing is filed twice and nothing is lost. Letters you write while the post office is closed go into an outbox and are posted, in order, when it opens.

StepWhat you decide for the chat UI
Requirements1:1 and groups, instant send, correct order, offline outbox, receipts, typing, history scrollback, several tabs and devices
ArchitectureChatApp → ThreadList + ThreadView (MessageList, Composer); a store; a sync engine owning one WebSocket; IndexedDB
Data modelMessages keyed by clientId with server id and seq; per-thread lastSeq, readSeq; a persisted outbox
InterfaceSocket frames send / ack / message / read / typing; REST history ?before=seq; /sync?after=seq
OptimisationsOptimistic send, seq ordering and gap fill, backoff with jitter, one socket for all tabs, reverse virtualised list

The heart is the merge: optimistic messages, acks and echoes that race each other, and an outbox that survives going offline:

chat-client.mjs
// A chat thread on the client. Every message the user sends gets a clientId at once;
// the server later assigns its own seq (the thread's real order) and echoes the message to everyone.
function createThread() {
  const msgs = new Map();            // clientId (or server id) → message
  const outbox = [];                 // clientIds not yet accepted by the server, in send order
  let online = true, localN = 0;
  const transport = [];              // what we "sent" over the socket, for the printout

  const flush = () => { for (const id of outbox) { msgs.get(id).status = "sending"; transport.push(id); } };
  const upsert = (m) => {            // one merge path for acks and echoes: whichever arrives first wins the slot
    const key = m.clientId ?? m.id;
    msgs.set(key, { ...msgs.get(key), ...m, status: "sent" });
    const i = outbox.indexOf(key); if (i >= 0) outbox.splice(i, 1);
  };
  return {
    send(text) {
      const clientId = `c${++localN}`;
      msgs.set(clientId, { clientId, text, status: online ? "sending" : "queued" });
      outbox.push(clientId); if (online) transport.push(clientId);
    },
    ack: (clientId, seq) => upsert({ clientId, seq }),       // reply to our own send
    received: (m) => upsert(m),                               // a message pushed over the socket
    goOffline() { online = false; },
    goOnline() { online = true; flush(); },                   // resend in the original order
    sent: () => transport.splice(0).join(","),
    // Confirmed messages by server seq; unconfirmed ones after them, in the order they were typed.
    render() {
      const all = [...msgs.values()];
      const done = all.filter((m) => m.seq != null).sort((a, b) => a.seq - b.seq);
      const wait = all.filter((m) => m.seq == null);
      return [...done, ...wait].map((m) => `${m.seq ?? "…"}:${m.text}${m.status === "sent" ? "" : `(${m.status})`}`).join("  ");
    },
  };
}

const t = createThread();
t.send("hi"); t.send("are you there?");
console.log("1", t.render(), "| wire:", t.sent());
t.received({ id: "s101", seq: 101, text: "hey!" });           // Bob's message reaches the server first
t.received({ clientId: "c2", seq: 103, text: "are you there?" }); // echo of c2 beats its own ack
t.ack("c1", 102);
t.ack("c2", 103);                                              // late ack: merges, no duplicate
console.log("2", t.render());
t.goOffline(); t.send("on the train"); t.send("signal is bad");
console.log("3", t.render(), "| wire:", t.sent() || "(nothing)");
t.goOnline();
console.log("4", t.render(), "| wire:", t.sent());
t.ack("c4", 105); t.ack("c3", 104);                            // acks can come back in any order
console.log("5", t.render());
Result
1 …:hi(sending)  …:are you there?(sending) | wire: c1,c2
2 101:hey!  102:hi  103:are you there?
3 101:hey!  102:hi  103:are you there?  …:on the train(queued)  …:signal is bad(queued) | wire: (nothing)
4 101:hey!  102:hi  103:are you there?  …:on the train(sending)  …:signal is bad(sending) | wire: c3,c4
5 101:hey!  102:hi  103:are you there?  104:on the train  105:signal is bad
What’s happening
  1. send() creates a message keyed by a fresh clientId (c1, c2; in a browser, crypto.randomUUID()) and shows it at once as …:hi(sending). It has no server seq yet, so it renders after all confirmed messages, in the order it was typed. Both go out on the wire: c1,c2.
  2. Bob's "hey!" reaches the server first and gets seq 101. Then the server echoes our c2 to everyone in the thread, including us, with seq 103, and that echo arrives before our own ack for c2. Both paths go through one upsert() keyed by clientId, so the echo fills in the existing c2 entry instead of adding a second copy. This race happens in production all the time, because acks and broadcasts travel different paths on the server.
  3. The acks arrive (c1 → 102, then the late c2 → 103) and merge into the same entries. The render sorts confirmed messages by seq: 101:hey! 102:hi 103:are you there?. Our "hi" was typed before Bob's message arrived but is shown after it, because the server's order is the truth everyone sees.
  4. Offline, send() still shows the message instantly, marked (queued), and nothing goes on the wire. The outbox keeps c3, c4 in typing order.
  5. goOnline() flushes the outbox in order and marks the messages (sending). The acks come back out of order (c4 before c3), which doesn't matter: each lands on its own entry, and the render sorts by the seqs the server assigned (104, 105).
  6. If you keyed messages by server id only, the optimistic message (which has no id) and the echo would be two entries, and the user would see their own message twice for a moment and then, depending on the cleanup, once or twice forever.
AdvancedChat UI requirements and questions to ask

Requirements

Questions to ask:

  • 1:1 only, or groups? How big can a group get (10? 10,000)? Big groups change receipts (no per-member "read by" list) and typing indicators (throttled or summarised).
  • Message types: text, images and files, replies, reactions, edits and deletes? Each needs its own optimistic path.
  • Receipts and presence: sent / delivered / read ticks? Online dots? Typing indicators?
  • Offline: should the user be able to read history and compose while offline? (Usually yes on mobile.)
  • Devices: the same account in several tabs and on a phone, all in sync?
  • Search and history: how far back can you scroll, and is search client-side or server-side?

Non-functional:

  • Send feels instant: the message appears in under 100 ms of pressing Enter, whatever the network.
  • Never lose, never duplicate: every message typed is delivered exactly once from the user's point of view.
  • Consistent order on every device in the thread.
  • Cheap when idle: one connection, small heartbeats, nothing when the tab is hidden for long.
  • Accessible: new messages announced politely (a role="log" container is a polite live region by definition in WAI-ARIA), the composer labelled, keyboard reachable actions on each message.
AdvancedChat client storage and heartbeat estimates

Estimates

The client-side numbers are about how much history to keep on the device, how much to render, and what an idle connection costs. Thread counts and message sizes are assumptions for a busy workspace:

chat-estimates.mjs
// Assumptions for one user's client: a busy workspace with many threads.
const threads = 200;
const msgsPerThreadKept = 500;     // recent history cached on the device
const bytesPerMsg = 400;           // JSON: ids, author, text, timestamps, reactions
const firstScreen = 30;            // messages needed to paint an open thread
const nodesPerMsg = 15;
const heartbeatSec = 30;           // ping interval that keeps proxies from closing an idle socket
const pingBytes = 2 + 4 + 12;      // frame header + client mask + '{"t":"ping"}' (JS can't send ping frames)

const cacheMiB = (threads * msgsPerThreadKept * bytesPerMsg) / 1024 / 1024;
console.log(`local history: ${cacheMiB.toFixed(1)} MiB → IndexedDB, not localStorage (5 MiB per origin)`);
console.log(`open a thread: ${firstScreen} msgs = ${((firstScreen * bytesPerMsg) / 1024).toFixed(1)} KiB, ${firstScreen * nodesPerMsg} DOM nodes`);
console.log(`a 5,000-message thread fully mounted: ${(5000 * nodesPerMsg).toLocaleString("en")} DOM nodes → virtualise`);
const perDay = (86_400 / heartbeatSec) * pingBytes;
console.log(`heartbeats: ${86_400 / heartbeatSec} pings/day, ${(perDay / 1024).toFixed(0)} KiB/day per tab; 5 tabs open = 5 sockets unless tabs share one`);
Result
local history: 38.1 MiB → IndexedDB, not localStorage (5 MiB per origin)
open a thread: 30 msgs = 11.7 KiB, 450 DOM nodes
a 5,000-message thread fully mounted: 75,000 DOM nodes → virtualise
heartbeats: 2880 pings/day, 51 KiB/day per tab; 5 tabs open = 5 sockets unless tabs share one
What’s happening
  1. 200 threads × 500 recent messages × 400 bytes is 38 MiB of local history. That rules out localStorage, which browsers cap at 5 MiB per origin (MDN, "Storage quotas and eviction criteria", checked October 2026) and which is synchronous, blocking the main thread on every read. IndexedDB is asynchronous and shares a much larger per-origin quota (in Chrome up to 60% of the disk, per the same MDN page).
  2. Opening a thread needs only the last ~30 messages: under 12 KiB and 450 DOM nodes. The rest of the history loads as the user scrolls up.
  3. A 5,000-message thread fully mounted would be 75,000 nodes. The message list is virtualised like a feed, but anchored at the bottom (next deep dive).
  4. A heartbeat every 30 seconds keeps proxies and load balancers from closing an idle socket. Browsers can't send WebSocket ping frames from JavaScript, so the heartbeat is a tiny application message: 2,880 a day, about 51 KiB per tab. Cheap for one socket; but five open tabs means five sockets and five copies of every message, which is why tabs should share one connection.
AdvancedChat client architecture and sync engine

Architecture

<ChatApp> ThreadList ThreadView MessageList role="log" Composer typing · attachments Store threads messages by clientId outbox lastSeq · readSeq Sync engine one WebSocket for all tabs backoff + jitter flush outbox in order gap fill by seq IndexedDB history + outbox survive reloads WebSocket gateway send · ack · message · typing REST: history, sync ?before=seq · ?after=seq send
Components never talk to the network; the sync engine does, and everything it learns goes through the store, which persists to IndexedDB.

The key boundary is between the store and the sync engine. Components only read the store and dispatch intents ("send this text"). The sync engine owns the connection: it reconnects with exponential backoff and jitter, flushes the outbox, fills gaps and writes everything it learns into the store. That makes offline a non-event for the UI: "send" always does the same thing, and only the status on the message changes.

Why a WebSocket here and SSE for the feed: chat is genuinely two-way and chatty (sends, typing, read receipts, presence), so a single full-duplex connection beats an SSE stream plus a POST per keystroke-sized event. History and catch-up stay on REST, where caching and pagination are easy.

AdvancedChat message and thread model

Data model

chat-model.js
const message = {
  clientId: "6f1c…",          // made by the sender's device; the idempotency key
  id: "m_9QK…",               // assigned by the server on accept (null until then)
  threadId: "t1",
  seq: 103,                   // the thread's order, assigned by the server: 1, 2, 3 … no gaps
  authorId: "u42",
  text: "are you there?",
  createdAtClient: "2026-10-24T09:12:03.120Z",  // shown only while pending
  createdAt: "2026-10-24T09:12:03.410Z",        // server time, shown once confirmed
  status: "sent",             // queued | sending | sent | delivered | read | failed
};
const thread = { id: "t1", lastSeq: 105, readSeq: 103, unread: 2, draft: "" };
What’s happening
  1. clientId is generated on the device and travels with the message to the server, which stores it with a unique constraint per sender. A retry after a dropped connection carries the same clientId, so the server can answer "already have it, here's its seq" instead of creating a duplicate: the same idempotency-key idea as in Idempotency and exactly-once.
  2. seq is per thread and gap-free. That's a backend promise (one writer orders each thread), and it's what lets the client detect missed messages (next deep dive). Message ids from a Snowflake-style generator are sortable but have gaps, so they can't do that job.
  3. There are two timestamps. The client's clock can be minutes wrong, so it's only shown while the message is pending; once confirmed, the server's time is shown, and order always comes from seq, never from either clock.
  4. readSeq is a single number per thread rather than a flag per message: "read up to 103". Marking a thread read is one write, and unread count is lastSeq - readSeq minus your own messages.
  5. draft lives with the thread so switching threads and coming back keeps what you were typing; persisted to IndexedDB, it survives a reload too.
AdvancedChat socket protocol and REST API

Interface

Chat protocol (WebSocket frames, JSON)
client → server   {"t":"send",   "clientId":"6f1c…", "threadId":"t1", "text":"hi"}
server → client   {"t":"ack",    "clientId":"6f1c…", "id":"m_9QK…", "seq":102}
server → client   {"t":"message","threadId":"t1", "seq":101, "id":"m_9QJ…", "authorId":"u7", "text":"hey!", "clientId":"…"}
client → server   {"t":"read",   "threadId":"t1", "seq":103}
client ↔ server   {"t":"typing", "threadId":"t1"}          at most every 3 s; expires after 5 s
client → server   {"t":"ping"}  /  server → client {"t":"pong"}   every 30 s

REST
GET /api/threads/t1/messages?before=101&limit=50     older history while scrolling up
GET /api/sync?after=104&thread=t1                     everything missed while disconnected

The component contracts: MessageList takes threadId and reads messages from the store; it emits onLoadOlder() and onVisible(seq) (to advance readSeq). Composer takes threadId and emits onSend(text, attachments) and onTyping(). Neither knows a socket exists.

AdvancedDeep dive: ordering and gaps

Because every thread's messages are numbered 1, 2, 3 … with no holes, the client always knows whether it has everything. A message that jumps ahead reveals a gap, and the client asks for exactly the missing range:

gap-fill.mjs
// Every message in a thread has a server seq: 1, 2, 3 … with no holes. So the client can tell
// when it missed something (a dropped socket, a lost push) and ask for exactly that range.
function createThreadSync(lastSeq, fetchRange) {
  const have = new Set();
  let contiguous = lastSeq;                 // everything up to here is known to be present
  return {
    receive(seq) {
      if (seq <= contiguous || have.has(seq)) return console.log(`seq ${seq}: duplicate, ignored`);
      have.add(seq);
      if (seq > contiguous + 1) {
        console.log(`seq ${seq}: gap after ${contiguous} → GET /threads/t1/messages?after=${contiguous}&upTo=${seq - 1}`);
        fetchRange(contiguous + 1, seq - 1);
      }
      while (have.has(contiguous + 1)) have.delete(++contiguous);
      console.log(`seq ${seq}: contiguous up to ${contiguous}`);
    },
    // After a reconnect: one request for everything after the last contiguous seq.
    resumeFrom: () => `GET /sync?thread=t1&after=${contiguous}`,
  };
}

const pendingFetches = [];
const sync = createThreadSync(100, (from, to) => pendingFetches.push([from, to]));
sync.receive(101);
sync.receive(104);                          // 102 and 103 were lost
sync.receive(101);                          // a replay after reconnect
for (const [from, to] of pendingFetches.splice(0)) for (let s = from; s <= to; s++) sync.receive(s);
console.log("on reconnect:", sync.resumeFrom());
Result
seq 101: contiguous up to 101
seq 104: gap after 101 → GET /threads/t1/messages?after=101&upTo=103
seq 104: contiguous up to 101
seq 101: duplicate, ignored
seq 102: contiguous up to 102
seq 103: contiguous up to 104
on reconnect: GET /sync?thread=t1&after=104
What’s happening
  1. The thread was synced up to 100. Message 101 arrives: it's contiguous + 1, so contiguous advances to 101.
  2. 104 arrives next. That's more than 101 + 1, so 102 and 103 were missed (a push lost during a socket hiccup). The client requests exactly after=101&upTo=103 and keeps 104 in have, but contiguous stays at 101: the UI can show 104, but the client won't claim it's caught up.
  3. A replayed 101 (servers often resend recent messages after a reconnect) is <= contiguous, so it's ignored without touching anything.
  4. The fetched 102 advances contiguous to 102; 103 advances it to 103, and the while loop then sweeps up the waiting 104. Everything is contiguous to 104.
  5. On reconnect, the client asks for one thing: everything after 104. That's the whole catch-up protocol, and it works because of the gap-free seq. With timestamps instead, "everything after 09:12:03" misses messages written in the same millisecond or by a server with a slightly slow clock.
AdvancedDeep dive: offline, reconnects and many tabs
  • Detecting offline. navigator.onLine only says whether there's a network interface; it's true on a captive-portal Wi-Fi with no internet. The real signal is the socket: missed pongs (no reply to two heartbeats) mean the connection is dead even if the browser hasn't noticed.
  • Reconnecting. Exponential backoff with full jitter (for example 0.5 s, 1 s, 2 s … capped at 30 s, each randomised) so that a million clients dropped by one server restart don't all reconnect in the same second. Reconnect immediately on the online event and when the tab becomes visible.
  • The outbox. Persist it to IndexedDB before trying to send. Flush in order after the socket is back and the catch-up has been requested. The server dedups by clientId, so resending something that actually arrived before the drop is harmless. Background Sync could flush it with the tab closed, but it isn't Baseline (MDN marks it limited availability, Chromium-only, checked October 2026), so the fallback is to flush on the next open.
  • Many tabs. One socket per tab multiplies connections and duplicates notifications. Elect a leader tab (the Web Locks API, navigator.locks.request("chat-socket", …), gives this almost for free: whoever holds the lock owns the socket) and fan messages out to other tabs over a BroadcastChannel; or put the socket in a SharedWorker. When the leader tab closes, its lock is released and another tab takes over.
AdvancedDeep dive: the message list

A chat list is a feed upside down, and that changes three behaviours:

  • Stick to the bottom only if the user is at the bottom. If they're reading older messages, a new message must not yank them down; show a "↓ 3 new messages" pill instead, exactly like the feed banner.
  • Prepending history keeps the position. Loading 50 older messages above the viewport must not move what the user is looking at. Record the first visible message's offset before inserting and restore it after (or rely on overflow-anchor, which only works for non-virtualised content).
  • Virtualise from the bottom. Measure heights as messages render (long text, images and link previews vary wildly) and keep the anchor at the newest message.

The container is role="log", which WAI-ARIA defines as a live region with an implicit aria-live="polite", so screen readers announce new messages without interrupting. Group consecutive messages from the same author and insert date separators as derived data in the render, not as stored messages.

AdvancedChat UI trade-offs at a glance

Trade-offs and what to say

DecisionChooseBecauseThe other option wins when
TransportOne WebSocket, REST for historyTwo-way, chatty traffic; history stays cacheableSSE + POST: a read-mostly support chat
IdentityclientId from the device, seq from the serverMerges acks and echoes; server dedups retriesServer ids only: no optimistic UI at all
OrderPer-thread gap-free seqSame order everywhere; gaps are detectableTimestamps: never for order; fine for display
OfflinePersisted outbox in IndexedDB, flush in orderNothing typed is lost; works in every browserBackground Sync too, where available (Chromium)
TabsLeader tab (Web Locks) + BroadcastChannelOne socket per user per browserSocket per tab: simpler, fine for internal tools
StorageIndexedDB38 MiB of history, asynclocalStorage: tiny settings only (5 MiB, synchronous)
Echo before ack

A user sends "lunch?" (clientId: c9). Before the ack arrives, the socket delivers {t:"message", seq:210, clientId:"c9", text:"lunch?"}. Then the connection drops, and on reconnect the client resends its outbox. What does the user see, and what does the server do?

Show answer

One "lunch?" at seq 210, shown as sent; the server accepts the resend as a duplicate and returns the same seq.

What’s happening
  1. The echo carries clientId: "c9", so upsert merges it into the optimistic entry: seq 210, status sent, and c9 leaves the outbox. The user sees one message.
  2. Whether c9 is resent depends on whether the echo removed it from the outbox. In the model it does (upsert splices it out), so nothing is resent.
  3. If a client only cleared the outbox on the ack (a common design), c9 would be resent after the reconnect. The server finds (sender, c9) already stored and replies with an ack for seq 210 rather than writing a second message.
  4. Either way the user sees exactly one message: the client dedups by clientId on display, and the server dedups by clientId on write. You need both, because either side can be the one that retries.

#Design a collaborative document editor

Added

A collaborative editor (Google Docs, Notion, Figma's text) lets several people type into the same document at once. Each person sees their own keystrokes instantly, because waiting for a server round trip on every key is unusable, so every client edits its own copy and sends its changes to the others. The hard part follows directly: two people make changes at the same time, each based on a version the other no longer has, and every copy must still end up identical. That property is called convergence, and the two families of techniques for it are operational transformation (OT) and conflict-free replicated data types (CRDTs).

The mental model: two people editing the same printed page over the phone. "Insert an s at character 3" is a perfectly clear instruction until the other person inserts something before character 3; then it puts the s in the wrong place. There are two fixes. OT rewrites the instruction before applying it ("you inserted one character before mine, so mine is now at 4"). A CRDT never uses positions at all ("insert s after the t that Alice typed"), so the instruction means the same thing in any order.

StepWhat you decide for the editor
RequirementsReal-time co-editing, instant local typing, convergence, cursors and presence, offline edits, history, undo of your own changes
ArchitectureEditor view over a local document model; a sync layer (OT client or CRDT doc); a per-document collaboration server over WebSocket; op log + snapshots
Data modelOT: text + revision + op log. CRDT: items with unique ids and anchors, tombstones; presence kept separately and never persisted
Interfacejoin {docId, rev} → snapshot; op {baseRev, ops} → ack / remote ops; presence {anchor}; REST snapshot and history
OptimisationsBatching ops, one op in flight (OT), compaction and snapshots, relative cursor anchors, throttled presence

OT in its smallest form: two concurrent inserts into the word "cat", applied naively and then with a transform:

ot-inserts.mjs
// Operational transformation for the simplest case: two concurrent inserts into the same text.
const apply = (doc, op) => doc.slice(0, op.pos) + op.text + doc.slice(op.pos);

// Rewrite op `a` so it can be applied AFTER `b`, which happened concurrently.
// If b inserted before a's position, a shifts right by b's length. Ties go to the lower site id.
function transform(a, b) {
  const bFirst = b.pos < a.pos || (b.pos === a.pos && b.site < a.site);
  return bFirst ? { ...a, pos: a.pos + b.text.length } : a;
}

function run(label, start, alice, bob, useOT) {
  // Each side applies its own op at once, then the other's op when it arrives.
  const atAlice = apply(apply(start, alice), useOT ? transform(bob, alice) : bob);
  const atBob = apply(apply(start, bob), useOT ? transform(alice, bob) : alice);
  console.log(`${label.padEnd(26)} alice "${atAlice}"  bob "${atBob}"  ${atAlice === atBob ? "converged" : "DIVERGED"}`);
}

const alice = { site: "a", pos: 3, text: "s" };   // "cat" → "cats"
const bob = { site: "b", pos: 1, text: "h" };     // "cat" → "chat"
run("no transform", "cat", alice, bob, false);
run("with transform", "cat", alice, bob, true);

// Same position: without a tie-break each side would put its own text first.
const a0 = { site: "a", pos: 0, text: "A" }, b0 = { site: "b", pos: 0, text: "B" };
run("same spot, no transform", "x", a0, b0, false);
run("same spot, with transform", "x", a0, b0, true);
Result
no transform               alice "chats"  bob "chast"  DIVERGED
with transform             alice "chats"  bob "chats"  converged
same spot, no transform    alice "BAx"  bob "ABx"  DIVERGED
same spot, with transform  alice "ABx"  bob "ABx"  converged
What’s happening
  1. Alice and Bob both start from "cat". Alice inserts "s" at position 3 ("cats"); Bob inserts "h" at position 1 ("chat"). Each applies their own op immediately, then receives the other's.
  2. Without transformation, Alice applies Bob's pos 1 to "cats" → "chats". Bob applies Alice's pos 3 to "chat", but position 3 in "chat" is between a and t → "chast". Same two ops, different order, diverged, and nobody gets an error: the documents are simply different forever.
  3. With transform(a, b), each side rewrites the incoming op against the op it already applied. At Bob's, Alice's op (pos 3) is transformed against Bob's (pos 1 < 3), so it shifts right by Bob's one character to pos 4: "chat" → "chats". At Alice's, Bob's op is at pos 1, before Alice's pos 3, so it stays. Both read "chats".
  4. When both insert at the same position, "who goes first" has no natural answer, and without a rule each side puts its own text first: "BAx" vs "ABx". The tie-break by site id ("a" < "b", so Alice's text goes first everywhere) makes both "ABx". Any deterministic rule works, as long as every replica uses the same one.
  5. Real OT also transforms deletes against inserts, deletes against deletes, and formatting ranges, and the combinations multiply. That's why production OT is server-centred: the server defines one order of operations so each client only ever transforms against that order (next sections).
AdvancedEditor requirements and questions to ask

Requirements

Questions to ask:

  • What kind of document? Plain text, rich text (bold, headings, lists, tables), or structured (a canvas, a spreadsheet)? Rich text is a tree, not a string, and that changes the model.
  • How many simultaneous editors? 2–10 is normal; 100+ (a company all-hands doc) changes presence and fan-out. Many more viewers than editors?
  • Offline? Minutes (a train tunnel) or days (edit on a plane, merge on landing)? Long offline periods favour CRDTs.
  • History and undo: version history with names and restore? Undo should undo my last change, not someone else's.
  • Comments, suggestions, permissions (view, comment, edit)?

Non-functional:

  • Local edits are instant: typing never waits for the network.
  • Remote edits appear quickly: a few hundred milliseconds end to end is the usual target (a rule of thumb; measure it).
  • Convergence is guaranteed, not "usually": a diverged doc is silent data corruption.
  • Nothing is lost on refresh, crash or disconnect.
  • Accessible: the editor is a real text editing surface (contenteditable or a textarea-backed model) that screen readers and IMEs can use; remote cursors are decorative and not announced on every move.
AdvancedEditor op rate and metadata estimates

Estimates

The numbers that shape the sync layer are operation rate, fan-out and metadata size. The typing speed is the 52 wpm average from the CHI 2018 typing study; editors per doc, tick length and op size are assumptions:

editor-estimates.mjs
// Assumptions: a busy shared doc during a meeting.
const editors = 20;                 // people typing at the same time
const keysPerSec = (52 * 5) / 60;   // 52 wpm × 5 chars per word ≈ 4.3 keystrokes/s
const tickMs = 50;                  // the server sends each client one combined update per tick
const opBytes = 60;                 // one op on the wire: ids, anchor, text, version
const docChars = 150_000;           // ~50 pages
const crdtBytesPerChar = 24;        // naive per-character id + anchor + flags (real CRDTs compress runs)

const opsIn = editors * keysPerSec;
const perClientRaw = (editors - 1) * keysPerSec;     // one message per remote keystroke
const perClientTicked = Math.min(perClientRaw, 1000 / tickMs);
console.log(`ops into the server: ${opsIn.toFixed(0)}/s`);
console.log(`each client, one message per remote op: ${perClientRaw.toFixed(0)} msgs/s`);
console.log(`each client, batched per ${tickMs} ms tick: ${perClientTicked} msgs/s, ${(perClientRaw * opBytes / 1024).toFixed(1)} KiB/s of ops`);
console.log(`text: ${(docChars / 1024).toFixed(0)} KiB; naive CRDT metadata: ${(docChars * crdtBytesPerChar / 1024 / 1024).toFixed(1)} MiB, before tombstones`);
Result
ops into the server: 87/s
each client, one message per remote op: 82 msgs/s
each client, batched per 50 ms tick: 20 msgs/s, 4.8 KiB/s of ops
text: 146 KiB; naive CRDT metadata: 3.4 MiB, before tombstones
What’s happening
  1. 20 people typing at 4.3 keystrokes a second each is 87 ops per second into the document's server. That's per document, so the collaboration server routes each document to one process (sticky by docId) and that process can easily order everything for it.
  2. Naively every remote keystroke is a message: each client receives 82 messages a second. The server instead collects ops and sends each client one combined update per 50 ms tick: 20 messages a second carrying the same 4.8 KiB/s of ops. Fewer messages means fewer renders and less per-message overhead on mobile.
  3. The text of a 50-page document is about 146 KiB. A naive per-character CRDT that stores an id, an anchor and flags for every character (24 bytes is an assumption) needs 3.4 MiB before counting deleted characters, which are kept as tombstones. Production CRDTs such as Yjs compress runs of consecutively typed characters into single items, which is how they stay practical; the naive figure is why that optimisation exists.
AdvancedEditor architecture and document owner

Architecture

Browser Editor view contenteditable · remote cursors local document model Sync layer OT: 1 in flight + buffer or CRDT doc (merge any order) presence: throttled anchors Collaboration server one owner per docId orders + transforms ops batches per 50 ms tick relays presence Op log append-only, by rev Snapshots every N revs Presence memory only ops
Each document has one owner process that orders its ops; the browser always edits locally first. Presence takes the same socket but is never stored.

Talk through it from the keystroke: the editor view turns a keystroke into an operation on the local model, which re-renders immediately. The sync layer queues the op and sends it over the WebSocket. The collaboration server that owns this document (routing by docId, so one process sees all its ops) orders it, transforms it if needed, appends it to the op log, and broadcasts it to the other clients, who apply it to their models. Every N revisions the server writes a snapshot so loading a doc means "latest snapshot + the ops after it", not replaying from the beginning of time.

In practice you don't write the editor view or the CRDT yourself: you pick an editor framework (ProseMirror, Lexical, Slate, Tiptap) and a sync library (Yjs or Automerge for CRDTs, or ShareDB-style OT). The interview is about why, and what the pieces must guarantee.

AdvancedEditor model: OT, CRDT and presence

Data model

editor-model.js
// OT: the server's view
const doc = { id: "d1", rev: 1042, snapshotRev: 1000, text: "…" };
const opLogEntry = { rev: 1043, clientId: "a7", baseRev: 1041, ops: [{ insert: "s", at: 3 }] };

// OT: each client's sync state
const client = { rev: 1042, inflight: null, buffer: [] }; // at most one op batch awaiting ack

// CRDT: every character (or run of characters) is an item with a unique id and an anchor
const item = { id: [17, "a7"], after: [16, "a7"], text: "s", deleted: false };

// Presence: separate, ephemeral, never written to the log
const presence = { userId: "u42", name: "Asha", color: "#7c3aed", anchor: { item: [17, "a7"], side: "after" }, at: 1729760000 };
What’s happening
  1. In OT, the server keeps the authoritative rev (revision number). Every op records the baseRev it was made against; if the server is now at a later rev, it transforms the op against everything since baseRev before appending it. That's why the op log is ordered by rev and append-only.
  2. Each OT client has at most one batch in flight and buffers new local ops until the ack. Google's 2010 Drive blog post "Making collaboration fast" describes exactly this: each client tracks its last synced revision, pending changes, sent-but-unacknowledged changes and its current document, and "we never send more than one pending change at a time". It keeps the transforms between one client and the server simple, because the server never sees two unacknowledged batches from the same client.
  3. A CRDT item is identified by [counter, site] and positioned by after, the id of the item it was typed after. Deleting sets deleted: true (a tombstone) instead of removing it, because a concurrent insert may still refer to it as its anchor.
  4. A cursor is stored as an anchor to an item, not as an integer offset. An offset would be wrong after the next remote insert above it; an anchor stays attached to the character the user is next to.
  5. Presence is deliberately outside the document: it's high-frequency, only meaningful right now, and storing it would bloat history with millions of cursor moves.
AdvancedCollaboration protocol and editor props

Interface

Collaboration protocol (WebSocket, JSON)
client → server  {"t":"join",   "docId":"d1", "rev":1040}               resume from rev, or 0 for a fresh load
server → client  {"t":"ops",    "from":1041, "ops":[…]}                  catch-up (or a snapshot if too far behind)
client → server  {"t":"op",     "baseRev":1042, "ops":[{"insert":"s","at":3}], "clientSeq":7}
server → client  {"t":"ack",    "clientSeq":7, "rev":1043}
server → client  {"t":"remote", "rev":1044, "ops":[…], "by":"u7"}        batched per 50 ms tick
client ↔ server  {"t":"presence","anchor":{…}, "selection":{…}}        throttled to ~10 per second

REST
GET  /api/docs/d1                → { snapshot, rev }       first paint (server-rendered for read-only viewers)
GET  /api/docs/d1/history?before=rev
POST /api/docs/d1/versions       { name }                  a named version is a pointer to a rev

The editor component's own interface is mostly configuration: docId, user (name and colour for presence), readOnly, and callbacks such as onPresenceChange(users) for the avatar stack. The sync provider is passed in, so the same editor works against a local-only doc in tests.

AdvancedDeep dive: the CRDT way

A sequence CRDT gives every character a permanent identity, so no operation ever mentions a position. This model is a minimal RGA-style list: three people edit "cat" concurrently, and a fourth replica receives their ops in every possible order:

sequence-crdt.mjs
// A tiny sequence CRDT (RGA style). Every character has a unique id [counter, site] and remembers
// the id of the character it was typed after. No positions travel over the wire, so nothing needs transforming.
const gt = (x, y) => x[0] !== y[0] ? x[0] > y[0] : x[1] > y[1];   // a total order on ids
const same = (x, y) => x && y && x[0] === y[0] && x[1] === y[1];

function replica(site) {
  const chars = [];                 // { id, after, ch, deleted }
  let clock = 0;
  const integrate = (op) => {
    clock = Math.max(clock, op.id[0]);
    if (op.type === "del") { chars.find((c) => same(c.id, op.id)).deleted = true; return; }
    let i = op.after ? chars.findIndex((c) => same(c.id, op.after)) + 1 : 0;
    while (i < chars.length && gt(chars[i].id, op.id)) i++;      // newer siblings stay first
    chars.splice(i, 0, { ...op, deleted: false });
  };
  return {
    insertAfter(afterIndex, ch) {   // a local edit: index into the visible text (-1 = start)
      const visible = chars.filter((c) => !c.deleted);
      const op = { type: "ins", id: [clock + 1, site], after: visible[afterIndex]?.id ?? null, ch };
      integrate(op); return op;
    },
    remove(index) {
      const op = { type: "del", id: chars.filter((c) => !c.deleted)[index].id };
      integrate(op); return op;
    },
    integrate,
    text: () => chars.filter((c) => !c.deleted).map((c) => c.ch).join(""),
  };
}

// Setup shared by everyone: "cat", typed by site "z".
const base = replica("z");
const seed = [base.insertAfter(-1, "c"), base.insertAfter(0, "a"), base.insertAfter(1, "t")];

// Three people edit concurrently, each from "cat".
const fresh = (site) => { const r = replica(site); seed.forEach(r.integrate); return r; };
const [A, B, C] = [fresh("a"), fresh("b"), fresh("c")];
const ops = [
  A.insertAfter(2, "s"),        // alice: "cats"
  B.insertAfter(0, "h"),        // bob:   "chat"
  C.insertAfter(0, "o"),        // carol: "coat" (same spot as bob)
  C.remove(3),                  // carol then deletes "t": "coa"
];

// Deliver the ops in every order that keeps carol's two ops in her order, to a fourth replica.
const perms = (xs) => xs.length ? xs.flatMap((x, i) => perms([...xs.slice(0, i), ...xs.slice(i + 1)]).map((p) => [x, ...p])) : [[]];
const results = new Set();
let orders = 0;
for (const order of perms([0, 1, 2, 3]).filter((p) => p.indexOf(2) < p.indexOf(3))) {
  const r = fresh("d");
  order.forEach((k) => r.integrate(ops[k]));
  results.add(r.text()); orders++;
}
console.log(`${orders} delivery orders → ${results.size} result: ${[...results]}`);
Result
12 delivery orders → 1 result: cohas
What’s happening
  1. The seed "cat" is three items with ids [1,"z"], [2,"z"], [3,"z"], each anchored after the previous one. Every replica integrates the same seed.
  2. Concurrently: Alice inserts s after t; Bob inserts h after c; Carol inserts o after c (the same anchor as Bob) and then deletes t. None of them has seen the others' ops; each op carries its own id ([4,"a"], [4,"b"], [4,"c"]) and an anchor id, never a number position.
  3. integrate() finds the anchor and then skips past newer siblings: anything already right after the anchor with a greater id stays in front. Bob's h [4,"b"] and Carol's o [4,"c"] have the same counter, so the site breaks the tie ("c" > "b"), and o comes first on every replica, whichever arrives first.
  4. Carol's delete marks t as a tombstone. Alice's s still finds t as its anchor because the item is still there, just hidden, and lands after it. That's why CRDTs keep tombstones.
  5. The 4 ops have 24 orderings; 12 keep Carol's insert before her delete (the only causal constraint here). All 12 produce "cohas". No server ordered anything and nothing was transformed: convergence comes from the data structure, which is why CRDTs suit peer-to-peer and long offline editing.
  6. Yjs uses an adaptation of the YATA algorithm in this family, with runs of characters stored as single items (Yjs internals docs, checked October 2026); Automerge's list type is RGA-based. Neither is something you write in an interview, but you should be able to explain the id-and-anchor idea above.
AdvancedDeep dive: OT or CRDT?
OT (server-ordered)CRDT
Where convergence comes fromThe server's single order + transform functionsThe data structure: merges in any order
Needs a central serverYes, in practiceNo (works peer-to-peer, or with a dumb relay)
Long offline editingPainful: long transform chains on reconnectNatural: merge the two histories
MetadataSmall: plain text + op logIds per item, tombstones; needs compaction
Rich text and intentMature in production editorsMature now (Yjs bindings for ProseMirror, Lexical, Tiptap)
ExampleGoogle Docs (a revision log of operations, transformed on the clients and the server, per Google's 2010 Drive blog)Yjs, Automerge; Figma uses a CRDT-inspired server-authoritative model (Figma engineering blog)

What to say: if there's a server anyway and offline is short, both work; CRDTs win when edits must merge without a server or after long offline periods; OT wins on metadata size and has decades of production use. Figma's post makes a useful point for interviews: because Figma has a central server, it took the CRDT ideas (per-property last-writer-wins on objects) without the full decentralised machinery, and it didn't need OT because a design canvas isn't a text sequence.

AdvancedDeep dive: presence, cursors and undo
  • Cursors are anchors. Store remote cursors as "after item X" (CRDT) or transform their offsets through every incoming op (OT). An integer cursor not transformed jumps to the wrong place after any remote insert above it.
  • Throttle presence to about 10 updates a second per user and drop stale ones (only the latest position matters, unlike ops, where every one matters). Above a few dozen editors, show an avatar stack and only draw cursors near the viewport.
  • Undo is selective. Ctrl+Z must undo my last change, even if others have typed since. The undo stack holds inverses of my own ops, transformed (OT) or anchored (CRDT) through everyone else's changes. Yjs's UndoManager does this by tracking only local changes by default; remote updates arrive with a different transaction origin and aren't recorded (Yjs docs, trackedOrigins, checked October 2026).
  • Offline: keep local ops in IndexedDB. On reconnect an OT client sends its buffer against its old baseRev and the server transforms it through everything since; a CRDT client simply exchanges missing updates with the server (both sides send what the other lacks, summarised by a state vector in Yjs).
AdvancedEditor trade-offs at a glance

Trade-offs and what to say

DecisionChooseBecauseThe other option wins when
ConvergenceOT with a per-doc server, or a CRDT libraryOT: small metadata; CRDT: any order, offlineCRDT for local-first/peer-to-peer; OT if you already run an OT stack
RoutingOne owner process per docIdOne place orders the doc's opsStateless relays: only with CRDTs (any order is fine)
Fan-outBatch per 50 ms tick20 instead of 82 messages/s per clientPer-op: tiny docs, 2 editors
StorageOp log + snapshots every N revsFast load, full historySnapshots only: no history needed
PresenceSeparate, throttled, memory onlyHigh-frequency, worthless tomorrowPersisted: never

#Design an e-commerce product page and checkout

Added

This prompt is really two designs that share a cart. The product page is public, read-heavy and judged on speed and search ranking: it must be in Google's index, appear fast on a mid-range phone, and show a correct price and stock level. The checkout is a write path judged on correctness and trust: never charge twice, never trust a price the browser sent, keep card numbers away from your servers, and survive a flaky network between "Pay" and "Thank you". The cart connects them, and it has a classic problem of its own: the shopper fills a cart as a guest, then signs in to an account that already has a cart.

The mental model is a shop window and a till. The window must be bright, fast and visible from the street, so you build it ahead of time and put it close to the customer (server rendering, CDN, structured data). The till must be exact and must never ring up the same sale twice, so it checks every price itself, hands the card to the payment terminal (the provider's iframe) rather than touching it, and gives every sale a receipt number before taking the money (an idempotency key).

StepWhat you decide for the shop
RequirementsProduct page: images, variants, price, stock, reviews, add to cart, SEO. Checkout: guest or signed in, address, shipping, payment, confirmation
ArchitectureServer-rendered product page cached at the CDN with client islands (price/stock, add to cart, recommendations); a separate checkout route; cart and order APIs; provider-hosted payment fields
Data modelProduct with variants (SKU, price, stock); server-side cart by owner or guest token; checkout session with an idempotency key
InterfaceGET /products/:slug (HTML), availability API, cart API with merge on login, POST /orders with Idempotency-Key
OptimisationsLCP hero image, partial hydration, short-TTL price/stock, cart merge rules, double-submit protection, webhooks for payment truth

Start with the problem interviewers like to probe: merging a guest cart into an account cart at sign-in. This runs on the server; the client calls it and explains the result:

cart-merge.mjs
// Merging the guest cart (built before sign-in) into the account cart. Runs on the server at login;
// the client shows the result and explains any change.
const MAX_PER_ITEM = 5;

function mergeCarts(account, guest, catalog) {
  if (account.mergedGuestIds.includes(guest.id)) return { cart: account, notes: ["already merged"] };
  const lines = new Map(account.lines.map((l) => [l.sku, { ...l }]));
  const notes = [];
  for (const g of guest.lines) {
    const line = lines.get(g.sku);
    // Same item in both: the newer intent wins. The guest cart is what the shopper just did.
    lines.set(g.sku, line ? { ...line, qty: g.qty, addedAt: Math.max(line.addedAt, g.addedAt) } : { ...g });
    if (line && line.qty !== g.qty) notes.push(`${g.sku}: qty ${line.qty} (account) vs ${g.qty} (this visit) → ${g.qty}`);
  }
  for (const [sku, l] of lines) {                       // re-check every line against the live catalog
    const item = catalog[sku];
    if (!item || item.stock === 0) { lines.delete(sku); notes.push(`${sku}: no longer available, removed`); continue; }
    const cap = Math.min(MAX_PER_ITEM, item.stock);
    if (l.qty > cap) { notes.push(`${sku}: only ${cap} allowed, qty ${l.qty} → ${cap}`); l.qty = cap; }
    if (l.price !== item.price) notes.push(`${sku}: price changed ${l.price} → ${item.price}`);
    l.price = item.price;                               // the client's price is never trusted
  }
  const cart = { ...account, lines: [...lines.values()], mergedGuestIds: [...account.mergedGuestIds, guest.id] };
  return { cart, notes };
}

const catalog = {
  mug: { price: 12, stock: 40 }, tee: { price: 25, stock: 3 }, lamp: { price: 60, stock: 0 }, sock: { price: 8, stock: 99 },
};
const account = { id: "cart-u42", mergedGuestIds: [], lines: [
  { sku: "mug", qty: 1, price: 12, addedAt: 100 },
  { sku: "lamp", qty: 1, price: 55, addedAt: 120 },     // saved last week, now sold out
] };
const guest = { id: "guest-7f3", lines: [
  { sku: "mug", qty: 2, price: 12, addedAt: 900 },
  { sku: "tee", qty: 4, price: 22, addedAt: 910 },      // old price cached in the page
  { sku: "sock", qty: 3, price: 8, addedAt: 920 },
] };

const first = mergeCarts(account, guest, catalog);
console.log(first.cart.lines.map((l) => `${l.sku}×${l.qty} @${l.price}`).join(", "));
first.notes.forEach((n) => console.log("  note:", n));
const total = first.cart.lines.reduce((s, l) => s + l.qty * l.price, 0);
console.log("total", total);
// The login response is retried (flaky network): merging again must not change anything.
const again = mergeCarts(first.cart, guest, catalog);
console.log("second merge:", again.notes.join(), "| same lines:", JSON.stringify(again.cart.lines) === JSON.stringify(first.cart.lines));
Result
mug×2 @12, tee×3 @25, sock×3 @8
  note: mug: qty 1 (account) vs 2 (this visit) → 2
  note: lamp: no longer available, removed
  note: tee: only 3 allowed, qty 4 → 3
  note: tee: price changed 22 → 25
total 123
second merge: already merged | same lines: true
What’s happening
  1. The account cart (saved last week) has mug×1 and a lamp at 55. The guest cart (this visit) has mug×2, tee×4 at a stale price of 22, and sock×3. The merge goes line by line by SKU.
  2. mug is in both. The rule is the newer intent wins: the shopper just chose 2 in this visit, so the result is 2, not 1 + 2 = 3. Summing is the naive rule and produces carts people didn't ask for; whichever rule you pick, a note tells the shopper what changed.
  3. Every line is re-checked against the live catalogue: the lamp is sold out and removed; the tee is capped at the 3 in stock (and the per-item maximum of 5); the tee's price is updated from 22 to 25. The price the browser had is never trusted; the server always prices the cart.
  4. The result is mug×2 @12, tee×3 @25, sock×3 @8, total 123, plus four notes for the UI ("The price of Tee changed", "Lamp is no longer available").
  5. The login response is retried (a flaky network), so the merge runs a second time. The guest cart's id is already in mergedGuestIds, so it returns immediately and the lines are identical. Without that record a retry would apply the guest cart again, which with a summing rule would double every quantity.
AdvancedShop requirements and questions to ask

Requirements

Questions to ask:

  • Catalogue size and change rate: 10,000 products or 50 million? How often do prices and stock change (flash sales)? This decides how much can be prebuilt and how fresh price and stock must be.
  • Variants: size and colour combinations with their own SKU, price, stock and images?
  • Who shops: guest checkout allowed? Saved addresses and cards for signed-in users?
  • Payments: cards only, or wallets (Apple Pay, Google Pay), buy-now-pay-later, local methods? Is 3-D Secure (Strong Customer Authentication in Europe) required?
  • Markets: several currencies, languages, tax rules?
  • Inventory guarantee: do we reserve stock when it's added to the cart, at checkout start, or only on payment?

Non-functional:

  • Product page LCP of 2.5 s or less and CLS of 0.1 or less at p75 (web.dev "good" thresholds, checked October 2026). It's the landing page from search and ads, so it's also where speed most directly affects revenue.
  • Indexable: the product's name, price, availability and reviews are in the HTML the crawler receives, with structured data.
  • Correct money: prices, tax and totals computed on the server; one order per checkout attempt, however many times "Pay" is clicked.
  • Secure: card data never touches our servers or our JavaScript; the payment page runs as few third-party scripts as possible.
  • Accessible forms: labelled fields, inline errors linked with aria-describedby, error summary on submit, correct autocomplete tokens so browsers and password managers fill addresses and cards.
AdvancedProduct page LCP critical path

Estimates

The decision that matters most for the product page is rendering, and it can be estimated. This rough critical path uses Lighthouse's default mobile profile (150 ms round trip, 1.6 Mbit/s, from Lighthouse's throttling.md, checked October 2026); file sizes are assumptions, and it ignores TCP slow start, parallel downloads and CPU time:

product-lcp.mjs
// A rough critical path to the hero image (the LCP element) on Lighthouse's mobile profile:
// 150 ms round trip, 1.6 Mbit/s. Simplified: no TCP slow start, no parallel downloads, no CPU time.
const rtt = 150, mbps = 1.6;
const dl = (kib) => (kib * 1024 * 8) / (mbps * 1e6) * 1000;    // ms to download
const connect = 3 * rtt;                                         // DNS + TCP + TLS 1.3, roughly
const server = 200;                                              // time to render the page or answer the API

const steps = {
  "SSR + hero in HTML": [
    ["connect", connect], ["HTML (server renders product)", rtt + server + dl(40)],
    ["CSS", rtt + dl(25)], ["hero image (preloaded, fetchpriority=high)", rtt + dl(110)],
  ],
  "CSR (empty shell)": [
    ["connect", connect], ["HTML shell", rtt + 50 + dl(5)], ["CSS", rtt + dl(25)],
    ["JS bundle", rtt + dl(300)], ["GET /api/product", rtt + server + dl(8)], ["hero image", rtt + dl(110)],
  ],
};
for (const [name, list] of Object.entries(steps)) {
  let t = 0;
  console.log(name);
  for (const [what, ms] of list) { t += ms; console.log(`  ${what.padEnd(44)} +${ms.toFixed(0).padStart(4)} ms → ${t.toFixed(0)} ms`); }
  console.log(`  LCP ≈ ${(t / 1000).toFixed(2)} s ${t <= 2500 ? "(within 2.5 s)" : "(over the 2.5 s 'good' line)"}`);
}
Result
SSR + hero in HTML
  connect                                      + 450 ms → 450 ms
  HTML (server renders product)                + 555 ms → 1005 ms
  CSS                                          + 278 ms → 1283 ms
  hero image (preloaded, fetchpriority=high)   + 713 ms → 1996 ms
  LCP ≈ 2.00 s (within 2.5 s)
CSR (empty shell)
  connect                                      + 450 ms → 450 ms
  HTML shell                                   + 226 ms → 676 ms
  CSS                                          + 278 ms → 954 ms
  JS bundle                                    +1686 ms → 2640 ms
  GET /api/product                             + 391 ms → 3031 ms
  hero image                                   + 713 ms → 3744 ms
  LCP ≈ 3.74 s (over the 2.5 s 'good' line)
What’s happening
  1. Both paths pay the same 450 ms to connect (DNS, TCP and TLS, roughly three round trips).
  2. Server-rendered: the HTML already contains the product, and the hero image is referenced in it with fetchpriority="high" (or a <link rel="preload">). After the HTML and CSS, the browser fetches the image directly: LCP at about 2.0 s, inside the 2.5 s line. In a real browser the CSS and the image download in parallel, so it's better than this sequential sum.
  3. Client-rendered: the HTML is an empty shell, so nothing about the product is known until a 300 KiB JavaScript bundle downloads (1.7 s on this link), runs, calls /api/product and only then learns which image to fetch. Three dependent steps put LCP at about 3.7 s, well over the line, before counting the CPU time to run the bundle on a slow phone.
  4. The lesson isn't "never use JavaScript": it's that the LCP resource must be discoverable from the HTML. That rules out client-only rendering for this page and drives the architecture below. See Rendering strategies for an app and Web performance budgets and Core Web Vitals.
AdvancedProduct page and checkout architecture

Architecture

Product page (SSR, CDN) title · images · specs · JSON-LD price/stock add to cart recommendations (deferred) Checkout route address · shipping · review provider's card iframe availability API cart API recs API orders API Idempotency-Key Payment provider card data, 3-D Secure Catalogue + inventory prices are computed here, never taken from the browser purge CDN on change hook
The product page is mostly static HTML from the CDN with small interactive islands; the checkout talks to the orders API and leaves card data to the provider's iframe (dashed: card data goes only to the provider).

The product page is split by how fresh each part must be:

  • Product content (title, description, images, specs, reviews summary) changes rarely. Render it on the server and cache the HTML at the CDN for minutes or hours, purging it when the catalogue changes (incremental static regeneration in Next.js terms). This is what the crawler and the LCP measurement see.
  • Price and stock can change by the minute in a sale. Either render them with a short CDN TTL (seconds), or render a server value and refresh it from the availability API in a small client island after load. Either way the add-to-cart response is the final word.
  • Interactive parts (variant picker, add to cart, image gallery) are islands: only they ship and hydrate JavaScript. The rest of the page is HTML that never needs to run code. In React 19 terms these are client components inside a server-rendered page; in Angular, deferred blocks with incremental hydration do the same job.
  • Personalised and below-the-fold parts (recommendations, "recently viewed") load after the main content, so they never delay LCP and never make the cached HTML user-specific.

The checkout is a separate route with its own bundle, never cached at the CDN (it's per user), and with the strictest Content Security Policy on the site (next deep dives).

AdvancedProduct, cart and checkout model

Data model

commerce-model.js
const product = {
  id: "p918", slug: "canvas-sneaker",
  title: "Canvas sneaker", images: [{ key: "products/918/front.jpg", w: 2400, h: 1800, alt: "…" }],
  variants: [
    { sku: "918-BLU-42", attrs: { color: "blue", size: 42 }, price: { amount: 6900, currency: "EUR" }, stock: 3 },
    { sku: "918-BLU-43", attrs: { color: "blue", size: 43 }, price: { amount: 6900, currency: "EUR" }, stock: 0 },
  ],
  rating: { avg: 4.6, count: 312 },
};
const cart = { id: "cart-u42", ownerId: "u42", guestToken: null, mergedGuestIds: [], version: 7,
               lines: [{ sku: "918-BLU-42", qty: 1, priceSeen: 6900 }] };
const checkout = { id: "ck-81", cartId: "cart-u42", cartVersion: 7, idempotencyKey: "0b6e…",
                   address: { /* … */ }, shipping: "standard", paymentIntent: "pi_…", state: "awaiting_payment" };
What’s happening
  1. Money is an integer number of minor units plus a currency (6900 cents = €69.00). Floating-point euros produce totals like 68.99999999 and tax rounding bugs; integers don't.
  2. Variants each have their own SKU, price and stock, and the URL carries the chosen variant (/p/canvas-sneaker?size=42) so it can be shared, with a rel="canonical" to the base product so search engines don't index every combination as a separate page.
  3. The cart is server-side, owned by a user or by a guest token in a cookie. A cart that only lives in localStorage can't follow the shopper from phone to laptop and can't be validated before payment. priceSeen is stored only to tell the shopper "the price changed since you added this".
  4. version increments on every cart change. The checkout records the cart version it was started with; if the cart changes in another tab, placing the order fails with a clear "your cart changed, please review" instead of charging for a different set of items.
  5. The checkout's idempotencyKey is created once per checkout attempt and stored with it, so every retry of "Pay" (double click, timeout, second tab) carries the same key.
AdvancedShop APIs and component contracts

Interface

Shop APIs
GET  /p/canvas-sneaker?size=42                 HTML, Cache-Control: public, s-maxage=300 (purged on change)
GET  /api/products/p918/availability           { "918-BLU-42": { "price": 6900, "stock": 3 } }   s-maxage=10
POST /api/cart/lines        { "sku": "918-BLU-42", "qty": 1 }   → 200 { cart }  or 409 { "error": "out_of_stock" }
POST /api/cart/merge        { "guestCartId": "guest-7f3" }      → 200 { cart, notes: [...] }   (called after sign-in)
POST /api/checkout          { "cartId": "cart-u42" }            → 201 { checkoutId, idempotencyKey, totals }
POST /api/checkout/ck-81/orders
     Idempotency-Key: 0b6e…  { "cartVersion": 7 }               → 201 { orderId }  (same answer on every retry)
GET  /api/orders/o1                                             → { state: "paid" | "pending" | "failed" }

The component contracts worth naming: AddToCartButton takes a sku and reports onAdded(cart) or an error it can show inline ("Only 3 left"); VariantPicker takes the variants and the selected sku and emits onChange(sku), which also updates the URL; PaymentStep takes a checkoutId and mounts the provider's element, emitting onConfirmed(orderId).

AdvancedDeep dive: placing the order exactly once

The most expensive bug in checkout is a double charge. It comes from three ordinary events: a double click, a timeout after the server already created the order, and a second tab. One idempotency key per checkout attempt handles all three:

checkout-submit.mjs
// Placing the order: a double click, a retry after a timeout, or a second tab must create ONE order.
// The client makes one idempotency key per checkout attempt; the server remembers what each key produced.
const server = {
  orders: new Map(), byKey: new Map(), seq: 0, failNext: false,
  async placeOrder(key, cart) {
    await null;                                        // the network round trip
    if (this.byKey.has(key)) return { ...this.byKey.get(key), replayed: true };
    const order = { orderId: `o${++this.seq}`, total: cart.total };
    this.orders.set(order.orderId, order);
    this.byKey.set(key, order);
    if (this.failNext) { this.failNext = false; throw new Error("timeout (the order WAS created)"); }
    return order;
  },
};

function createCheckout(cart) {
  const key = "ck-" + cart.id + "-attempt-1";        // in a browser: crypto.randomUUID(), stored with the cart
  let state = "ready";                                 // ready → submitting → done | ready (on error)
  return {
    async submit() {
      if (state !== "ready") return console.log(`  click ignored (state ${state})`);
      state = "submitting";                            // the button is disabled in this state
      try {
        const r = await server.placeOrder(key, cart);
        state = "done";
        console.log(`  → ${r.orderId}${r.replayed ? " (replayed: same order, no new charge)" : ""}`);
      } catch (e) {
        state = "ready";                               // let the user try again, with the SAME key
        console.log(`  → error: ${e.message}; show "try again"`);
      }
    },
  };
}

const cart = { id: "cart-u42", total: 123 };
console.log("double click:");
const a = createCheckout(cart);
const first = a.submit();
await a.submit();                                      // the second click lands while the first is in flight
await first;
console.log("timeout, then retry:");
server.failNext = true;
const b = createCheckout({ id: "cart-u77", total: 40 });
await b.submit();
await b.submit();
console.log("second tab, same cart (state lost, key kept with the cart):");
await createCheckout(cart).submit();
console.log(`orders created: ${server.orders.size}`);
Result
double click:
  click ignored (state submitting)
  → o1
timeout, then retry:
  → error: timeout (the order WAS created); show "try again"
  → o2 (replayed: same order, no new charge)
second tab, same cart (state lost, key kept with the cart):
  → o1 (replayed: same order, no new charge)
orders created: 2
What’s happening
  1. Double click: the first click moves the state to submitting synchronously, before any await, and the button is disabled in that state. The second click lands while the first request is in flight, sees submitting and is ignored. One request, order o1.
  2. Timeout: the server creates order o2 and stores it under the checkout's key, but the response is lost (failNext throws after the write). The client can't know whether the order exists, so it returns to ready and shows "Try again", keeping the same key.
  3. The retry sends the same key. The server finds it in byKey and returns the stored order with replayed: true instead of creating another one. The shopper sees the confirmation for o2, and the card is charged once.
  4. Second tab: a fresh createCheckout for the same cart has no client state at all, but the key is derived from data stored with the cart (in production: the key returned by POST /api/checkout and kept on the server), so it's the same key and gets o1 back.
  5. Two orders exist in total, one per checkout attempt, never one per click. The client-side submitting state is only a courtesy that saves requests; the server-side key is the actual guarantee, which is the same idempotency pattern as in Design a payment system.
AdvancedDeep dive: payments without touching card data

The card fields are hosted by the payment provider inside iframes on your checkout page (Stripe's Payment Element, Adyen's Drop-in and similar). Card numbers go from the iframe straight to the provider; your page and your servers only ever see a token or a payment-intent id. That's what keeps a merchant eligible for PCI DSS's shortest self-assessment, SAQ A, rather than the much larger SAQ A-EP or SAQ D.

There's a 2025 change worth knowing: the PCI Security Standards Council's revised SAQ A (effective 1 April 2025, clarified in FAQ 1588; PCI SSC blog, checked October 2026) dropped requirements 6.4.3 and 11.6.1 as line items, but added an eligibility condition for merchants who embed a provider's payment form in their own page. They must confirm that their site "is not susceptible to attacks from scripts", either by applying controls like 6.4.3 (an inventory and authorisation of every script on the payment page) and 11.6.1 (detecting unauthorised changes to it) themselves, or by getting written confirmation from their provider that its embedded form includes such protection when implemented as instructed. A full redirect to the provider's hosted payment page doesn't have this condition. In design terms: the checkout page loads as few third-party scripts as possible (no chat widgets, A/B testing tools or tag managers on that route), with a strict CSP and Subresource Integrity on what remains. Skimming attacks inject a script into exactly this page.

The flow after "Pay":

  1. The client creates the order with the idempotency key; the server creates a payment intent with the provider and returns its client secret.
  2. The provider's SDK confirms the payment from the iframe. If the bank requires 3-D Secure, the provider shows its challenge (a modal or a redirect that returns to your return_url with the checkout id).
  3. The webhook from the provider to the orders service is the source of truth that the payment succeeded; the client's "success" callback is only a hint. The confirmation page polls GET /orders/o1 (or listens for a push) until the state is paid, showing "Confirming your payment…" meanwhile.
AdvancedShop trade-offs at a glance

Trade-offs and what to say

DecisionChooseBecauseThe other option wins when
Product page renderingSSR/ISR at the CDN + islandsLCP ~2.0 s vs ~3.7 s in the estimate; indexableCSR: logged-in back-office pages with no SEO need
Price and stockShort TTL or a client refresh islandFresh enough; add-to-cart is the final checkFully dynamic SSR: a flash sale where every second counts
CartServer-side, merged on login (newer intent wins, re-priced, idempotent)Cross-device, validated, no double mergesClient-only cart: a tiny shop without accounts
Order submissionIdempotency key per checkout attempt + disabled buttonOne order per attempt under clicks, timeouts and tabsNothing beats this; skipping it is never fine
Card entryProvider-hosted fields (iframe) or redirectCard data never touches us; SAQ A pathOwn card form: only processors and very large merchants
Payment truthProvider webhook, client polls order stateThe browser can close before the callback runsClient callback alone: never for money

#Design a data table

Added

Admin screens, dashboards and back-office tools are mostly tables: orders, users, invoices, logs. A data table sorts, filters, paginates, selects rows, maybe edits cells inline and exports. The first design decision decides most of the others: does the browser hold all the rows, or does the server? If all rows fit on the client, sorting and filtering are instant and offline-friendly; if they don't, every sort and filter is a query, and the table becomes a client for a search API. The second decision is that the table's view (sort, filters, page) belongs in the URL, so a support agent can send a colleague exactly what they're looking at.

The mental model: a desk and a warehouse. If the whole stock fits on your desk, you rearrange it on the desk: instant, no one else involved (client mode). If it's in a warehouse, you fill in a request slip describing what you want to see, sorted how, filtered how, and a clerk brings back one tray (server mode). Either way, the slip is the state: photocopy it (the URL) and a colleague gets exactly the same tray.

StepWhat you decide for the table
RequirementsSort (multi-column), filter, paginate or infinite scroll, select, inline edit, column show/hide/resize, export, shareable views
ArchitectureDataTable (toolbar, header, virtualised body, footer) over a table state synced with the URL; a data-source adapter: client (in memory) or server (query cache)
Data modelColumn definitions; state { sort[], filters{}, page, size, columns, selection }; rows keyed by id
InterfaceProps columns, dataSource(query), rowKey, selection; server GET /orders?sort=&f.status=&page= with allow-listed fields
OptimisationsClient/server decision by size, keep previous rows while loading, debounced filters, row virtualisation, cheap counts, locale-aware sort

The main model makes the two decisions concrete: where the work happens, and how the view round-trips through the URL:

table-state.mjs
// Data table: decide where sorting and filtering happen, and keep the view in the URL.
const COLUMNS = ["name", "status", "owner", "created", "amount"];
const DEFAULTS = { sort: [{ col: "created", dir: "desc" }], filters: {}, page: 1, size: 50 };

// Client-side only when the whole dataset is small enough to download once and sort in memory.
function chooseMode({ rows, bytesPerRow, budgetKiB = 1024, maxClientRows = 10_000 }) {
  const kib = (rows * bytesPerRow) / 1024;
  const where = rows <= maxClientRows && kib <= budgetKiB ? "client" : "server";
  return `${rows.toLocaleString("en")} rows × ${bytesPerRow} B = ${kib.toFixed(0)} KiB → ${where}`;
}

// State → query string. Defaults are left out so shared links stay short and stable.
function encode(state) {
  const p = new URLSearchParams();
  const sort = state.sort.map((s) => (s.dir === "desc" ? "-" : "") + s.col).join(",");
  if (sort !== "-created") p.set("sort", sort);
  for (const col of Object.keys(state.filters).sort()) p.set(`f.${col}`, state.filters[col]);
  if (state.page !== DEFAULTS.page) p.set("page", state.page);
  if (state.size !== DEFAULTS.size) p.set("size", state.size);
  return p.toString();
}

// Query string → state. The URL is user input: unknown columns and bad numbers are dropped, not trusted.
function decode(qs) {
  const p = new URLSearchParams(qs);
  const sort = (p.get("sort") ?? "-created").split(",")
    .map((t) => ({ col: t.replace(/^-/, ""), dir: t.startsWith("-") ? "desc" : "asc" }))
    .filter((s) => COLUMNS.includes(s.col));
  const filters = {};
  for (const [k, v] of p) if (k.startsWith("f.") && COLUMNS.includes(k.slice(2)) && v) filters[k.slice(2)] = v.slice(0, 100);
  const int = (v, d, max) => (Number.isInteger(+v) && +v >= 1 ? Math.min(+v, max) : d);
  return { sort: sort.length ? sort : DEFAULTS.sort, filters, page: int(p.get("page"), 1, 10_000), size: int(p.get("size"), 50, 200) };
}

console.log(chooseMode({ rows: 2_000, bytesPerRow: 200 }));
console.log(chooseMode({ rows: 40_000, bytesPerRow: 200 }));
console.log(chooseMode({ rows: 8_000, bytesPerRow: 400 }));

const view = { sort: [{ col: "status", dir: "asc" }, { col: "amount", dir: "desc" }], filters: { owner: "rohit", status: "open" }, page: 3, size: 50 };
const qs = encode(view);
console.log("url  ?" + qs);
console.log("same after round trip:", JSON.stringify(decode(qs)) === JSON.stringify(view));
console.log("default view:", JSON.stringify(encode(DEFAULTS)));
console.log("hostile:", JSON.stringify(decode("sort=password,-amount&f.secret=1&page=-4&size=99999")));
Result
2,000 rows × 200 B = 391 KiB → client
40,000 rows × 200 B = 7813 KiB → server
8,000 rows × 400 B = 3125 KiB → server
url  ?sort=status%2C-amount&f.owner=rohit&f.status=open&page=3
same after round trip: true
default view: ""
hostile: {"sort":[{"col":"amount","dir":"desc"}],"filters":{},"page":1,"size":200}
What’s happening
  1. chooseMode turns the decision into numbers: rows × bytes per row against a payload budget (1 MiB here) and a row ceiling (10,000). 2,000 rows of 200 bytes is 391 KiB, so ship them once and sort in the browser. 40,000 rows is 7.8 MiB, and 8,000 wide rows is 3.1 MiB: both go to the server. The thresholds are assumptions to state and tune; the point is to decide by size, not by habit.
  2. encode writes only what differs from the default. The default sort (-created) and the default page size are omitted, so the default view has an empty query string ("") and every shared link is as short as it can be. Filter keys are sorted, so the same view always produces the same URL, which also makes it a stable cache key.
  3. The view sort by status, then amount descending; owner = rohit; status = open; page 3 becomes sort=status%2C-amount&f.owner=rohit&f.status=open&page=3. URLSearchParams percent-encodes the comma as %2C; browsers display it fine, and if you prefer readable URLs, repeated keys (sort=status&sort=-amount) avoid it.
  4. decode(encode(view)) gives back exactly view: the round trip is lossless, which is the property to test whenever you put state in a URL.
  5. A hand-edited URL is user input. sort=password names a column that isn't in the allow-list, so it's dropped; f.secret is dropped; page=-4 falls back to 1; size=99999 is clamped to 200. Without this, a URL can ask your API to sort by a private field or return a million rows.
AdvancedData table requirements and questions to ask

Requirements

Questions to ask:

  • How many rows, now and in two years? How wide is a row? This picks client or server mode (above).
  • Which operations: single or multi-column sort? Filters per column (text contains, ranges, enums, dates) or one global search? Server-side full-text search?
  • Navigation: numbered pages ("go to page 40"), "load more", or infinite scroll? Admin users usually want pages and a total; feeds want infinite scroll.
  • Row actions: select many and bulk-edit? Inline cell editing? Row detail drawer?
  • Columns: show/hide, reorder, resize, pin the first column? Saved per user?
  • Freshness: does the data change while you look at it (live orders)? Does a new row appear, or do you refresh?
  • Export: CSV of the current view (all matching rows, not just the page), which is a server job for big tables.

Non-functional:

  • Interactions feel immediate: a sort or filter shows feedback within 100 ms (the new header state, a subtle loading bar) even when the data takes longer, and INP stays under 200 ms.
  • Shareable and restorable: the URL fully describes the view; Back returns to the previous view.
  • Correct ordering for humans: "Item 2" before "Item 10", accents and case handled for the user's locale.
  • Accessible: real table semantics, sortable headers announced with their state, keyboard operable.
AdvancedData table size and load estimates

Estimates

For a table that's too big for the browser, the numbers show why, and what the virtualised client still has to handle. Sizes and user counts are assumptions for an operations team's order table:

table-estimates.mjs
// Assumptions: an admin table of orders.
const totalRows = 250_000;        // all orders the user may see
const cols = 12;
const nodesPerCell = 2;           // <td> + its content
const rowHeight = 36, viewport = 720, overscan = 10;
const bytesPerRow = 220;          // JSON for one row, gzip not counted
const pageSize = 50;
const usersAtPeak = 400;          // ops staff refreshing and filtering at the same time
const actionsPerMin = 6;          // sort / filter / page changes per user per minute

console.log(`whole table as JSON: ${(totalRows * bytesPerRow / 1024 / 1024).toFixed(0)} MiB → can't ship it, server-side`);
console.log(`all rows mounted: ${(totalRows * cols * nodesPerCell / 1e6).toFixed(1)} M DOM nodes`);
const mounted = Math.ceil(viewport / rowHeight) + 2 * overscan;
console.log(`virtualised: ${mounted} rows × ${cols} cols = ${(mounted * cols * nodesPerCell).toLocaleString("en")} DOM nodes`);
console.log(`one page: ${pageSize} rows = ${(pageSize * bytesPerRow / 1024).toFixed(1)} KiB`);
console.log(`server load at peak: ${(usersAtPeak * actionsPerMin / 60).toFixed(0)} queries/s, each sorted + filtered + counted`);
Result
whole table as JSON: 52 MiB → can't ship it, server-side
all rows mounted: 6.0 M DOM nodes
virtualised: 40 rows × 12 cols = 960 DOM nodes
one page: 50 rows = 10.7 KiB
server load at peak: 40 queries/s, each sorted + filtered + counted
What’s happening
  1. 250,000 rows at 220 bytes is 52 MiB of JSON: far past any payload budget, so this table is server mode. Even if it downloaded, sorting it would block the main thread for a noticeable time.
  2. Rendering every row would be 6 million DOM nodes. Even one page of 500 rows is 12,000 nodes; virtualising rows (fixed 36 px height, 20 visible plus 10 above and below) keeps it at 960 nodes regardless of page size.
  3. A 50-row page is under 11 KiB, so each sort or filter is a small, fast request; the latency is the database query, not the network.
  4. 400 people making 6 changes a minute is 40 queries a second, each a sort + filter + count. That's the number to give the backend team, and the reason the count may need to be approximate (next deep dives).
AdvancedData table architecture and data sources

Architecture

<DataTable> Toolbar: filters, columns Header: aria-sort Body virtualised rows selection, inline edit Footer: pages, count Table state sort[] · filters{} page · size columns · selection URL ?sort=-amount&f.status=open Data source client: in memory server: query cache Orders API allow-listed sort + filters indexed query, page count (maybe approximate) rows
The URL and the table state mirror each other; the data source hides whether rows are sorted in the browser or on the server.

The table component never knows where its rows come from. It renders from table state, and asks a data source for "rows for this state". A client source sorts and filters an in-memory array (in a Web Worker if it's large enough to take more than a frame); a server source turns the state into a request and caches responses by the encoded query string, so going back to a previous sort is instant. Switching a table from client to server mode as it grows is then a change of adapter, not a rewrite.

The URL is synchronised in both directions: user actions update state, which replaceStates (typing in a filter) or pushStates (changing sort or page, so Back undoes it) the URL; navigating Back or opening a link decodes the URL into state. That's URL state as the single source of truth for the view.

AdvancedColumn definitions and table state

Data model

table-model.js
const columns = [
  { id: "name", header: "Name", type: "text", sortable: true, filter: "contains", width: 220, pinned: "left" },
  { id: "status", header: "Status", type: "enum", options: ["open", "paid", "refunded"], sortable: true, filter: "in" },
  { id: "amount", header: "Amount", type: "money", sortable: true, filter: "range", align: "end" },
  { id: "created", header: "Created", type: "date", sortable: true, filter: "range" },
];
const state = {
  sort: [{ col: "created", dir: "desc" }],    // ordered: the first key wins, later ones break ties
  filters: { status: "open" },                 // per column, typed by the column's filter kind
  page: 1, size: 50,
  hidden: ["owner"], order: ["name", "status", "amount", "created"], widths: { name: 260 },
  selection: { mode: "some", ids: new Set(["o_17", "o_42"]) },  // or { mode: "all-matching", except: Set }
};
What’s happening
  1. Column definitions are data, not markup. The same definitions drive the header, the cell renderers, the filter UI, the server's allow-list and the CSV export, so adding a column is one entry.
  2. sort is an ordered array: the first entry is the primary sort, later ones break ties. Shift-click on a header usually appends a key.
  3. Layout preferences (hidden, order, widths) are per user, saved to the server or localStorage, and are not in the shareable URL: a colleague opening your link wants your rows, not your column widths.
  4. Selection is either a set of ids, or "all matching the current filter, except these". With server mode, "select all 12,408" can't mean 12,408 ids in memory; it means "this filter", and the bulk action sends the filter to the server.
AdvancedTable props and server API

Interface

Table API
GET /api/orders?sort=status,-amount&f.owner=rohit&f.status=open&page=3&size=50
→ 200 { "rows": [ … 50 rows … ], "total": 12408, "totalIsExact": true }

GET /api/orders/export?sort=…&f.status=open          → 202 { "jobId": "x1" }   (big exports run as a job)
PATCH /api/orders/o_17   { "status": "paid", "version": 4 }   → 200 | 409 conflict

The component's props: columns, dataSource: (query) => Promise<{ rows, total?, nextCursor? }>, rowKey(row), selectionMode: "none" | "single" | "multi", onRowOpen(row), onCellEdit(row, col, value), plus initialState and onStateChange(state) so the page decides how state is stored (URL, memory, user settings). The server enforces the same allow-list as the client's decode, because the client's checks are only a courtesy.

PATCH carries the row's version so an inline edit fails with 409 instead of silently overwriting a change someone else made a minute ago (optimistic concurrency); the cell shows the conflict and the current value.

AdvancedDeep dive: sorting that people trust

Client mode sorts in JavaScript, and the default string comparison is wrong for people:

table-sort.mjs
// Client-side multi-column sort that people don't complain about.
const rows = [
  { name: "Item 10", status: "open", amount: 5 },
  { name: "item 2", status: "closed", amount: 30 },
  { name: "Élan", status: "open", amount: 30 },
  { name: "Item 1", status: "open", amount: null },
  { name: "eagle", status: "closed", amount: 5 },
];

const naive = [...rows].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
console.log("naive <    :", naive.map((r) => r.name).join(" | "));

// Natural, case- and accent-aware order for the user's locale; "Item 2" before "Item 10".
const collator = new Intl.Collator("en", { numeric: true, sensitivity: "base" });
function compareBy(sort) {
  return (a, b) => {
    for (const { col, dir } of sort) {
      const x = a[col], y = b[col];
      if (x == null || y == null) { if (x !== y) return x == null ? 1 : -1; continue; } // empty cells last, either direction
      const c = typeof x === "number" ? x - y : collator.compare(x, y);
      if (c) return dir === "desc" ? -c : c;
    }
    return 0;                     // ties keep their previous order: Array.prototype.sort is stable (ES2019)
  };
}
console.log("collator   :", [...rows].sort(compareBy([{ col: "name", dir: "asc" }])).map((r) => r.name).join(" | "));
const multi = [...rows].sort(compareBy([{ col: "status", dir: "asc" }, { col: "amount", dir: "desc" }]));
console.log("status, -amount:", multi.map((r) => `${r.status}/${r.amount}/${r.name}`).join("  "));
Result
naive <    : Item 1 | Item 10 | eagle | item 2 | Élan
collator   : eagle | Élan | Item 1 | item 2 | Item 10
status, -amount: closed/30/item 2  closed/5/eagle  open/30/Élan  open/5/Item 10  open/null/Item 1
What’s happening
  1. The naive < comparison orders by UTF-16 code units. Capital I (73) comes before lowercase e (101) and i (105), so both capitalised "Item" rows come first and "item 2" is split off from them; "Item 10" sits right after "Item 1" because strings compare character by character, not as numbers; and "Élan" (É is 201) lands after every ASCII letter. Users read that as a bug.
  2. Intl.Collator("en", { numeric: true, sensitivity: "base" }) compares like a person: digits as numbers (Item 1 < item 2 < Item 10), case and accents ignored at the base level (eagle, Élan together under E). Creating one collator and reusing its compare is much faster than calling localeCompare with options on every comparison.
  3. compareBy walks the sort keys in order and returns at the first difference, so status asc, amount desc groups by status and orders by amount within each group: closed/30, closed/5, open/30, open/5, open/null.
  4. Empty cells (null) go last in both directions. Flipping desc must not move the blanks to the top, which is what happens if you compare null as 0 and negate.
  5. Ties return 0, and Array.prototype.sort is guaranteed stable since ES2019, so rows that tie keep their previous order. That's what makes "click Status, then Amount" behave predictably.
  6. In server mode the same rules must hold in SQL: an explicit collation on text columns (in PostgreSQL, an ICU collation; MySQL's utf8mb4_0900_ai_ci is accent- and case-insensitive), NULLS LAST, and the primary key as the final tie-breaker so pages don't overlap.
AdvancedDeep dive: server mode without the jank
  • Keep the previous rows on screen while the next query runs, with a thin progress bar and the header already showing the new sort arrow. Replacing the table with a spinner on every click makes it flash and loses the user's place. (TanStack Query calls this placeholderData: keepPreviousData.)
  • Debounce text filters (300 ms or so) and cancel superseded requests with an AbortController, exactly as in the autocomplete: a filter box is an autocomplete for rows.
  • Cache by the encoded query. The canonical query string is a perfect cache key: Back to the previous sort is instant. See Data fetching and client caching.
  • Counts are expensive. COUNT(*) over a filtered 250,000-row table on every page change can cost more than fetching the page. Options: cache counts per filter for a minute, show "1,000+" past a cap, or return an estimate from the query planner and say so (totalIsExact: false).
  • Deep pages: "go to page 4,000" with OFFSET 199950 makes the database read and discard 199,950 rows. If users need deep jumps, keep offset pagination but cap it; if they only page forward, use keyset (cursor) pagination and drop page numbers, as in Pagination at the API level.
AdvancedDeep dive: accessibility and the header

Use a real <table> when the table is for reading: screen readers get rows, columns and header announcements for free. Sorting is a <button> inside the <th>, and the header that's currently sorted carries aria-sort (ascending or descending); the others carry none. Only switch to the grid role (with arrow-key cell navigation, per the APG grid pattern) when cells themselves are interactive, like a spreadsheet; a grid that isn't really interactive is harder to use with a screen reader than a table.

Sortable table header
CSS
.wrap { max-height: 120px; overflow: auto; border: 1px solid #cbd5e1; border-radius: 8px; font: 13px system-ui, sans-serif; }
table { border-collapse: collapse; width: 100%; }
th { position: sticky; top: 0; background: #f1f5f9; text-align: start; padding: 0; height: 32px; }
th button { all: unset; box-sizing: border-box; display: block; width: 100%; height: 32px; padding: 0 8px; cursor: pointer; font-weight: 600; }
th[aria-sort] button { color: #1e3a8a; }
td { height: 30px; padding: 0 8px; border-top: 1px solid #e2e8f0; }
.num { text-align: end; font-variant-numeric: tabular-nums; }
HTML
<div class="wrap">
  <table aria-label="Orders" aria-rowcount="12409">
    <thead><tr aria-rowindex="1">
      <th scope="col"><button>Name</button></th>
      <th scope="col" aria-sort="descending" class="num"><button>Amount</button></th>
    </tr></thead>
    <tbody>
      <tr aria-rowindex="2"><td>Item 1</td><td class="num">30.00</td></tr>
      <tr aria-rowindex="3"><td>Item 2</td><td class="num">5.00</td></tr>
      <tr aria-rowindex="4"><td>Item 10</td><td class="num">4.50</td></tr>
      <tr aria-rowindex="5"><td>Item 11</td><td class="num">1.00</td></tr>
    </tbody>
  </table>
</div>
Resultlive, in your browser
What’s happening
  1. Each <th> is a columnheader in the accessibility tree, and the sort control inside it is a real button named "Amount", so it's in the tab order and activates with Enter or Space with no extra code.
  2. Only the Amount header has aria-sort="descending"; Name has none. Screen readers announce "Amount, sorted descending" when the user moves into that column. Putting aria-sort="none" on every other header adds noise; leaving it off is clearer.
  3. The CSS keys the sorted header's colour off th[aria-sort], so the visual state can't drift from what assistive technology is told (same trick as the combobox's aria-selected).
  4. After the test scrolls the wrapper 60 px, the header's top is still at the wrapper's top edge (plus the 1 px border): position: sticky keeps the column labels visible over long scrolls with no JavaScript.
  5. aria-rowcount="12409" (12,408 rows plus the header row) and aria-rowindex on each row tell a screen reader the real size and position when only a slice of rows is in the DOM, which is what a virtualised or paginated table needs.
  6. Amount cells are right-aligned with tabular-nums, and the test measures the right edges of 30.00 and 1.00 lining up: numbers compare by magnitude only if their digits are in columns.
AdvancedData table trade-offs at a glance

Trade-offs and what to say

DecisionChooseBecauseThe other option wins when
Where to sort/filterBy size: client under ~1 MiB / 10k rows, else serverInstant locally; impossible beyond a few MiBHybrid: server filter, then client sort of the result page
View stateEncoded in the URL, defaults omitted, decoded with an allow-listShareable, Back works, stable cache keyMemory only: embedded tables inside a modal
PagingNumbered pages with a capped offset, approximate countsAdmins want "page 3 of 249"Keyset + "load more": huge, append-only data
RenderingVirtualise rows for big pages960 nodes instead of millionsPlain rows: ≤ 100 rows (Find and print still work)
Semantics<table> + aria-sortFree screen-reader navigationgrid role: spreadsheet-style cell editing
Text orderIntl.Collator, numeric, base sensitivity"Item 2" before "Item 10"; accents handledByte order: machine ids and hashes

#Design a design system and component library

Added

A design system is the shared language of a company's interfaces: design tokens (named decisions such as colours, spacing, type and radii), components built from them (buttons, inputs, dialogs, tables), the documentation, and the rules for changing any of it. Designing one is a system design question because it has many consumers (dozens of apps and teams), a platform to keep compatible (frameworks, browsers, sometimes iOS and Android), non-functional requirements that every consumer inherits (accessibility, performance, theming), and a release process where one careless change breaks 35 products at once.

The mental model is a city's building code plus prefabricated parts. The code (tokens) says what things mean: "an action is this colour", "a gap between related items is this size". The prefab doors and windows (components) follow the code and come with safety built in (keyboard support, contrast, focus rings), so every building gets them right without each architect relearning it. And the city can't change the code overnight: existing buildings must keep standing, so changes are versioned, announced, and come with a way to upgrade.

StepWhat you decide for the design system
RequirementsConsumers (frameworks, apps, platforms), brands and themes, accessibility level, contribution model, versioning promise
ArchitectureTokens (DTCG JSON) → token build → CSS variables and typed constants → headless behaviours → styled components → docs, tests and releases
Data modelThree token tiers (primitive, semantic, component); a theme is a set of semantic mappings; components have a typed props contract
InterfaceComponent props conventions (variant, size, controlled/uncontrolled, ref forwarding); tokens as var(--…); themes via data-theme
OptimisationsTheme switching without re-render, tree-shaking, contrast checks in CI, visual regression, codemods and deprecation windows

The heart of theming is token resolution: a component asks for button.bg, and which hex value it gets depends on the theme, through a chain of references:

tokens.mjs
// Three layers of design tokens: primitives (raw values), semantic (meaning, per theme),
// component (what a button uses). References look like "{color.blue.600}", as in the DTCG format.
const primitive = {
  "color.white": "#ffffff", "color.gray.900": "#111827", "color.gray.100": "#f3f4f6",
  "color.blue.600": "#2563eb", "color.blue.300": "#93c5fd", "color.gray.400": "#9ca3af",
};
const semantic = {
  light: { "bg.page": "{color.white}", "text.body": "{color.gray.900}", "action.bg": "{color.blue.600}", "action.text": "{color.white}" },
  dark:  { "bg.page": "{color.gray.900}", "text.body": "{color.gray.100}", "action.bg": "{color.blue.300}", "action.text": "{color.gray.900}" },
};
const component = { "button.bg": "{action.bg}", "button.text": "{action.text}", "button.hint": "{color.gray.400}" };

function resolve(name, theme, trail = []) {
  if (trail.includes(name)) throw new Error(`cycle: ${[...trail, name].join(" → ")}`);
  const raw = component[name] ?? semantic[theme][name] ?? primitive[name];
  if (raw === undefined) throw new Error(`unknown token ${name}`);
  const ref = raw.match(/^\{(.+)\}$/);
  return ref ? resolve(ref[1], theme, [...trail, name]) : raw;
}

// WCAG 2.x contrast ratio: relative luminance of each colour, (L1 + 0.05) / (L2 + 0.05).
const lum = (hex) => {
  const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255)
    .map((c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
  return 0.2126 * r + 0.7152 * g + 0.0722 * b;
};
const contrast = (a, b) => { const [x, y] = [lum(a), lum(b)].sort((p, q) => q - p); return (x + 0.05) / (y + 0.05); };

for (const theme of ["light", "dark"]) {
  const bg = resolve("button.bg", theme), fg = resolve("button.text", theme), page = resolve("bg.page", theme);
  const hint = resolve("button.hint", theme);
  const vars = Object.keys(component).map((k) => `--${k.replace(".", "-")}: ${resolve(k, theme)}`).join("; ");
  console.log(`${theme}: ${vars}`);
  const r1 = contrast(fg, bg), r2 = contrast(hint, page);
  console.log(`  button text on button: ${r1.toFixed(2)}:1 ${r1 >= 4.5 ? "passes" : "FAILS"} 4.5:1`);
  console.log(`  hint text on page:     ${r2.toFixed(2)}:1 ${r2 >= 4.5 ? "passes" : "FAILS"} 4.5:1`);
}
semantic.light["action.bg"] = "{button.bg}";        // a bad edit: the button points at itself via action.bg
try { resolve("button.bg", "light"); } catch (e) { console.log(e.message); }
Result
light: --button-bg: #2563eb; --button-text: #ffffff; --button-hint: #9ca3af
  button text on button: 5.17:1 passes 4.5:1
  hint text on page:     2.54:1 FAILS 4.5:1
dark: --button-bg: #93c5fd; --button-text: #111827; --button-hint: #9ca3af
  button text on button: 9.84:1 passes 4.5:1
  hint text on page:     6.99:1 passes 4.5:1
cycle: button.bg → action.bg → button.bg
What’s happening
  1. Three tiers. Primitives are raw values with neutral names (color.blue.600). Semantic tokens say what a value is for (action.bg, text.body) and are defined per theme. Component tokens say what a component uses (button.bg → {action.bg}). Components only ever reference component or semantic tokens, never primitives.
  2. resolve("button.bg", "light") follows button.bg → {action.bg} → {color.blue.600} → #2563eb. In dark it follows the same first step, but action.bg maps to {color.blue.300}, so the button becomes #93c5fd. The button's own definition didn't change; only the semantic layer differs between themes, which is why adding a theme means writing one mapping, not touching 60 components.
  3. The resolver outputs CSS custom properties per theme (--button-bg: #2563eb; …). Those are what ship to the browser (next sections).
  4. Contrast is checked per theme, per pair, with the WCAG 2.x formula: relative luminance of each colour, then (L1 + 0.05) / (L2 + 0.05). White on #2563eb is 5.17:1 and dark text on #93c5fd is 9.84:1, both above the 4.5:1 that WCAG 2.2's 1.4.3 requires for normal text.
  5. button.hint references the primitive color.gray.400 directly, the mistake the tiers are meant to prevent. On the dark page it's 6.99:1, fine, but on the white page it's 2.54:1, a failure. Because it skipped the semantic layer, it can't adapt per theme; the fix is a semantic text.muted with a darker gray in light and the 400 in dark. This is exactly the bug a CI contrast check catches before any app ships it.
  6. A bad edit makes action.bg point at {button.bg}, which points back at action.bg. The resolver keeps a trail of names and throws cycle: button.bg → action.bg → button.bg instead of recursing until the stack overflows. Token builds must fail loudly on cycles and unknown references.
AdvancedDesign system requirements and questions

Requirements

Questions to ask:

  • Who consumes it? One React app, many React apps, a mix of React and Angular, or also iOS and Android? A mix pushes tokens to be platform-neutral (JSON compiled to each platform) and components towards either per-framework packages or Web Components.
  • Brands and themes: light and dark only, or several brands each with light and dark? High-contrast mode?
  • Accessibility target: WCAG 2.2 AA is the usual baseline; is it a legal requirement for these products?
  • Scope: tokens only, or tokens + components + page patterns (forms, tables, empty states)?
  • Contribution and governance: a central team builds everything, or product teams contribute and a core team reviews?
  • Release promise: how long will a deprecated API keep working? How often can apps upgrade?

Non-functional:

  • Accessible by default: every component meets WCAG 2.2 AA in every theme and follows the matching WAI-ARIA APG pattern, so apps get it right without trying.
  • Small and tree-shakeable: an app pays only for the components it imports.
  • Fast theme switching: no re-render of the app, no flash of the wrong theme on load.
  • Stable: semantic versioning, deprecation windows, no visual change shipped as a patch.
  • SSR-friendly: components render on the server without layout shift (no measuring in effects to decide layout).
AdvancedBundle and upgrade cost estimates

Estimates

A design system's numbers are about bundle cost per app and the cost of change across apps. Component sizes, token counts and upgrade effort are assumptions to adjust for your company:

ds-estimates.mjs
// Assumptions: a company design system used by many product teams.
const components = 60, avgKiB = 3.5;      // min+gzip per component, including its styles
const usedByTypicalApp = 14;
const tokens = 450, bytesPerVar = 38;     // "--color-action-bg: #2563eb;" with a semantic name
const themes = 2, brands = 3;
const apps = 35, upgradesPerYear = 4, hoursPerUpgrade = 3;

console.log(`whole library: ${(components * avgKiB).toFixed(0)} KiB; tree-shaken app: ${(usedByTypicalApp * avgKiB).toFixed(0)} KiB`);
const cssKiB = (tokens * bytesPerVar) / 1024;
console.log(`token CSS: ${cssKiB.toFixed(1)} KiB per theme; all ${themes * brands} brand×theme sets: ${(cssKiB * themes * brands).toFixed(0)} KiB (ship only the active brand)`);
console.log(`upgrade cost across apps: ${apps * upgradesPerYear * hoursPerUpgrade} engineer-hours/year → codemods and long deprecation windows pay off`);
Result
whole library: 210 KiB; tree-shaken app: 49 KiB
token CSS: 16.7 KiB per theme; all 6 brand×theme sets: 100 KiB (ship only the active brand)
upgrade cost across apps: 420 engineer-hours/year → codemods and long deprecation windows pay off
What’s happening
  1. 60 components at ~3.5 KiB min+gzip each is 210 KiB if an app imports the library as one blob. With ES modules and sideEffects: false declared in package.json, a bundler includes only the 14 components an app actually imports: 49 KiB. That difference is why the package format matters.
  2. 450 tokens as CSS variables is about 17 KiB per theme. Three brands × two themes is 100 KiB if you ship everything, so ship only the active brand's file and keep both of its themes in it (switching theme is then instant, with no extra request).
  3. 35 apps upgrading four times a year at three engineer-hours each is 420 hours a year spent just on upgrades. Cutting that is worth real investment: codemods that rewrite renamed props automatically, long deprecation windows, and batching breaking changes into rare major releases.
AdvancedDesign system layers and pipeline

Architecture

Tokens DTCG JSON primitive → semantic → component Token build --vars per theme tokens.ts iOS · Android Headless APG behaviours Components Button, Input, Dialog, Combobox, Table … @acme/ui Docs site live examples CI checks visual regression a11y + contrast bundle size Release semver · changelog codemods Apps 35 products React, Angular, mobile tokens only
Tokens are the platform-neutral source; components build on tokens and headless behaviours; nothing reaches the apps without passing the checks (dashed: platforms that take only the tokens).

The layers, bottom up, and why each exists:

  • Tokens are the single source of design decisions, stored as JSON in the format of the W3C Design Tokens Community Group. Its first stable version, 2025.10, was published on 28 October 2025, with a Color module and a Resolver module for themes and brands (W3C community group announcement, checked October 2026). It's a Community Group report, not a W3C standard, but tools have converged on it. A build step (Style Dictionary or similar) compiles tokens to CSS variables, TypeScript constants and native formats, so the iOS and Android apps share the decisions even if they don't share components.
  • Headless behaviours implement the APG patterns (combobox, dialog, menu, tabs) without any styling: focus management, keyboard handling, ARIA attributes. Teams often build on an existing library here (React Aria, Radix Primitives, the Angular CDK) because getting a dialog's focus trap or a combobox's IME handling right is months of work.
  • Styled components combine the two: they read tokens through CSS variables and get behaviour from the headless layer. Most apps only ever touch this layer.
  • Docs, CI and release are part of the system, not extras: every component has a live documented example, every change runs visual regression screenshots in every theme, automated accessibility checks and token contrast checks, and the bundle size is tracked.
AdvancedDesign tokens in DTCG JSON

Data model

tokens/semantic.light.json
{
  "action": {
    "bg":   { "$type": "color", "$value": "{color.blue.600}" },
    "text": { "$type": "color", "$value": "{color.white}" }
  },
  "space": {
    "inline-gap": { "$type": "dimension", "$value": { "value": 8, "unit": "px" } }
  }
}
What’s happening
  1. Each token is an object with $value and a $type (color, dimension, fontFamily, duration …). The $ prefix separates the spec's properties from group names, so a group can be called anything.
  2. "{color.blue.600}" is an alias: a reference to another token by its dotted path, exactly what the model's resolve() follows. Aliases are what make the tiers work.
  3. There's one semantic file per theme (semantic.light.json, semantic.dark.json) with the same token names and different aliases. The build fails if the files don't declare the same set of names, so a theme can't silently lack a token.
  4. Dimensions are an object with a number and a unit (the 2025.10 format allows only px and rem), so the build can convert them to points or dp for native platforms from the same source. Colour values are objects too, with a colorSpace, components and an optional hex (from the format's own examples); the model above keeps plain hex strings only to stay short.
AdvancedButton contract and theme switching

Interface

Button.tsx (contract sketch)
type ButtonProps = {
  variant?: "primary" | "secondary" | "danger" | "ghost";  // semantic names, never colours
  size?: "sm" | "md" | "lg";
  loading?: boolean;          // shows a spinner, keeps the width, sets aria-busy, blocks double submit
  iconStart?: ReactNode;
  children: ReactNode;        // the accessible name; icon-only buttons require aria-label instead
} & Omit<ComponentPropsWithRef<"button">, "className" | "style">;
What’s happening
  1. variant uses semantic names (primary, danger), not visual ones (blue, red), so a rebrand changes tokens and nothing in app code.
  2. The props extend the native <button> props, so type, disabled, onClick, aria-* and (in React 19) ref just work; the component is a better button, not a different API to learn.
  3. className and style are removed from the type on purpose. Allowing arbitrary overrides is the fastest way for 35 apps to drift into 35 different buttons; real needs become new variants or a documented escape hatch.
  4. loading encodes a behaviour every app needs and gets wrong individually: keep the width (no layout shift), announce busy state, ignore clicks while busy.
  5. This sketch is not compiled (it's an excerpt). In Angular the same contract is input() signals on a component with an attribute selector (button[acmeButton]), so the native button element and its semantics are kept.

Theme switching is a CSS concern, not a JavaScript one: themes are CSS variable sets scoped to an attribute, so switching sets one attribute on <html> and the browser restyles without re-rendering a single component. To avoid a flash of the wrong theme, a tiny inline script in <head> sets the attribute from the saved preference (or prefers-color-scheme) before the first paint.

AdvancedDeep dive: themes as CSS variable scopes

The resolver's output becomes CSS: the same component markup, two theme scopes, and every colour coming from a variable. Themes can also nest (a dark promo banner inside a light page):

Two themes, one button
CSS
.light { --bg-page: #ffffff; --text-body: #111827; --button-bg: #2563eb; --button-text: #ffffff; }
.dark  { --bg-page: #111827; --text-body: #f3f4f6; --button-bg: #93c5fd; --button-text: #111827; }
.theme { background: var(--bg-page); color: var(--text-body); padding: 12px; border-radius: 10px; margin-bottom: 8px; font: 14px system-ui, sans-serif; }
.btn { background: var(--button-bg); color: var(--button-text); border: 0; border-radius: 8px; height: 36px; padding: 0 16px; font: 600 14px system-ui, sans-serif; }
.btn:focus-visible { outline: 3px solid var(--button-bg); outline-offset: 2px; }
HTML
<div class="theme light" id="l">Light page <button class="btn">Save</button>
  <div class="theme dark" id="nested">Dark banner inside it <button class="btn">Upgrade</button></div>
</div>
<div class="theme dark" id="d">Dark page <button class="btn">Save</button></div>
Resultlive, in your browser
What’s happening
  1. .light and .dark define the same variable names with different values; .btn only ever says var(--button-bg). The test reads the computed colours: the light button is rgb(37, 99, 235) (#2563eb) with white text, the dark one rgb(147, 197, 253) (#93c5fd) with rgb(17, 24, 39) text, exactly the values the resolver produced.
  2. The nested .dark banner inside the light page gets the dark values, because custom properties inherit and the nearest scope wins. That's how a component can force a theme locally without any JavaScript context provider.
  3. Both buttons are 36 px tall: themes change colours, never sizes, so switching themes can't cause layout shift.
  4. The focus ring uses the theme's own action colour with an offset, so it's visible on both backgrounds. You can tab to the buttons in the live result to see it; the test can't trigger :focus-visible.
  5. For a page that only follows the OS setting, CSS light-dark() can do the same per property (color: light-dark(#111827, #f3f4f6)); it's Baseline 2024 (newly available) and needs color-scheme: light dark to switch (MDN, checked October 2026). Explicit scopes like these are still needed for a user-selected theme and for brands.
AdvancedDeep dive: versioning and distribution

What counts as a breaking change is the policy question interviewers push on. Removing or renaming a prop, a component or a token is breaking. So is changing a token's meaning, and arguably a visible change in layout (a button 4 px taller can break a dense toolbar). A team that ships those as minor versions teaches 35 apps to pin versions and never upgrade, and then the system is dead. The usual policy:

  • Semantic versioning with automated changelogs (Changesets or similar) from each pull request.
  • Deprecate, then remove: a deprecated prop keeps working for at least one major version, logs a development-only warning naming its replacement, and the next major removes it.
  • Codemods for every rename, run with one command (jscodeshift or ts-morph scripts), so most upgrades are "run it, review the diff".
  • Visual regression tests in every theme on every pull request, so a "harmless" token change shows up as changed screenshots before it ships.

Distribution: publish ES modules with "sideEffects" set correctly (CSS imports are side effects; list them) so bundlers can tree-shake; framework packages declare React or Angular as peer dependencies, so the app's single copy is used (two copies of React in one page break hooks). Ship tokens as their own package, because native apps and non-React apps need them without the components. If the company has several frameworks, Web Components (for example with Lit) give one implementation for all, at the cost of weaker server rendering and a less natural API in React; many teams instead keep shared tokens and headless logic and write thin per-framework wrappers. In a monorepo the library, docs and a few consuming apps live together so a change can be tested against real usage before release (see Micro-frontends and monorepos).

AdvancedDesign system trade-offs at a glance

Trade-offs and what to say

DecisionChooseBecauseThe other option wins when
Token tiersPrimitive → semantic → componentThemes and brands change one layerTwo tiers: a single-brand product with one theme
ThemingCSS variables scoped by attributeNo re-render, nestable, SSR-safeJS theme objects: React Native, canvas or email rendering
BehaviourBuild on a headless libraryAPG keyboard and focus behaviour is hardIn-house: unusual widgets or strict dependency rules
StylingZero-runtime CSS (CSS modules, static extraction)No style injection cost, works with server componentsRuntime CSS-in-JS: legacy codebases already built on it
PackagingES modules, sideEffects, peer deps, tokens separate49 KiB instead of 210 KiB; one React copySingle bundle: a CDN-script design system for CMS pages
ChangeSemver, deprecations, codemods, visual testsUpgrades stay cheap (420 h/yr otherwise)Fast and loose: only before the first external consumer

Written fresh for interviews (there was no Google Doc for this one). Runnable models were run on Node 26; figures and limits were checked against sources in October 2026.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.