Part 1 · 7 chapters · ~50 min

M1: A Data-Intensive Web App

A transactions table from ten thousand rows to ten million, in five rounds: everything in memory until the arithmetic says no, the server holding the data and the client a window, one entity across four views, selection as a predicate and export as a job, and freshness, long sessions and column growth. Each round names its number, its break, and what it paid.

6

The brief and the questions

the brief

"Ops needs a transactions page. They filter by status and date and customer, sort by amount, click into a transaction, mark it reviewed, and export what they are looking at. It should be fast. We have about 10,000 transactions so far."

The brief has one number (10,000) and one adjective (fast). The questions find the rest. They are the same questions for every data-intensive system, and the answers decide which round the design starts at.

the questions, and the answers for this system
  1. How many rows now, and in two years? 10k now; the business plans 2M a year → 5M in two years, 10M as the ceiling to design for.
  2. How big is a row? Twenty columns; ~200 bytes of JSON; three of them (customer name, reference, note) are strings.
  3. How many users, on what, where? 200 ops staff; office laptops on fibre; Chrome; sessions of several hours (leaks matter).
  4. How fresh? Within a minute; new transactions arrive continuously (tens per second at peak).
  5. What is the slowest acceptable interaction? A filter or sort change: 200 ms. Opening a detail: 100 ms. An export: minutes are fine with progress.
  6. What do they filter and sort on? Status (6 values), date range, customer (search), amount range; sort by date, amount, customer. Nothing else, until the next quarter.
  7. What do they do to rows? View, mark reviewed (single and bulk), export CSV. Bulk means "everything matching this filter" in practice.
  8. What is the cost of being wrong? A stale "reviewed" flag causes duplicate work (annoying, not dangerous); a bulk action on the wrong rows is a real incident.
  9. Offline? No.
the requirements, with numbers
  1. FR: filter by status, date range, customer, amount range; sort by date, amount, customer; paginate or scroll; open a detail; mark reviewed (one, or all matching); export CSV of the current filter.
  2. NFR: 10k rows today, 10M ceiling; filter/sort response under 200 ms; detail under 100 ms; freshness under 60 s; 200 concurrent users; office laptops; multi-hour sessions without memory growth; exports up to the full filtered set with progress.
what the questions changed
"10,000 transactions" with no growth question would have justified v1 forever. "10M in two years" says v1 is a six-month design, and "bulk means everything matching" says selection must eventually be a predicate. Both were in the questions, neither in the brief.
7

v1: everything in memory

code
// v1, in full: fetch all, filter in memory, virtualised rows, client-side CSV. right at 10k rows; known to die at ~150k
function Transactions() {
  const { data: rows = [] } = useQuery({ queryKey: ['transactions'], queryFn: () => api.transactions.all(), staleTime: 60_000 })
  const [filter, setFilter] = useState({ status: 'all', q: '' }); const [sort, setSort] = useState({ by: 'date', dir: 'desc' })
  const visible = useMemo(() => sortRows(filterRows(rows, filter), sort), [rows, filter, sort])   // 2 ms at 10k; 200 ms at 1M
  const virt = useVirtualizer({ count: visible.length, getScrollElement: () => ref.current, estimateSize: () => 36 })
  return <>
    <Filters value={filter} onChange={setFilter} />
    <div ref={ref} className="scroll">{virt.getVirtualItems().map(v => <Row key={visible[v.index].id} row={visible[v.index]} style={{ transform: `translateY(${v.start}px)` }} />)}</div>
    <button onClick={() => downloadCsv(visible)}>Export</button>
  </>
}
// the numbers v1 lives within: rows × 200 B × 3 < 100 MB (≈150k rows on a laptop); fetch < 2 s; filter < 200 ms. write them in the file header.
the design
  1. One fetch of all rows into a query cache with a one-minute staleTime (freshness requirement met by refetch on focus and a background interval).
  2. Filter and sort in memory: a pass over the array per change; 2 ms at 10k rows. Memoised on the inputs so a re-render without a change is free.
  3. A virtualised list (the algorithms course part 1) so 10k rows are 30 DOM nodes. Without it, 10k rows × 20 cells is 200k nodes: the browser course's layout budget blown at the first render.
  4. Detail: the row is already in memory; the panel reads it by id. 0 ms.
  5. Mark reviewed: a mutation; on success, patch the row in the cache (the array is one query; setQueryData maps over it) so every view of it updates.
  6. Export: build the CSV from the filtered array; a Blob; a download link. Instant at 10k.
why this is right, and for how long
  1. It meets every number for today with two days of work: filters are instant, detail is instant, export is instant, freshness is a refetch.
  2. Its limits are arithmetic: rows × 200 B × 3 against ~100 MB of comfortable heap: ~150k rows. Rows × 200 B against 2 s of fibre: ~1M rows (but 4G would be ~100k). The in-memory filter against 200 ms: ~1M rows. The first to break is memory, at ~150k rows: at 2M rows a year, in about a month after launch if the backlog is loaded, or in six if it grows live.
  3. Write the numbers in the file. A comment at the top: "holds all rows in memory; designed for under 150k; see v2 plan". The break is known; the date is not; the reader of the file in month five should know.
WHERE V1 DIES
rows × bytes against the tab, at each order of magnitude
swipe the figure sideways, or tap expand for full screen
1/6
10k
10k rows × 200 bytes = 2 MB of JSON; parsed objects ~6 MB (the JS course part 5: objects are ~3× their JSON); fetch on office fibre ~0.2 s; an in-memory filter (one pass, a few string compares per row) ~2 ms. v1 is comfortable: every number is under 5% of its budget.
8

Round two: a million rows

code
// v2: the server holds the data; the client holds filter + sort + cursor + one page
const key = ['transactions', { filter, sort, cursor }]
const { data, isPending, isPlaceholderData } = useQuery({
  queryKey: key, queryFn: ({ signal }) => api.transactions.page({ filter, sort, cursor, limit: 50 }, { signal }),
  placeholderData: keepPreviousData,       // the old page stays visible while the new one loads: no flash to empty
  staleTime: 30_000,
})
// filter change: wrap in startTransition so typing in the filter box stays responsive while the page refetches and re-renders
// cursor: data.nextCursor; "previous" via a cursor stack in state; "jump to date" is a filter (date_gte), not a page number
// the server: WHERE from the filter (validated against an allowlist of columns), ORDER BY (sort col, id) with a composite index per sort,
//   keyset condition from the cursor, LIMIT 51; the cursor is base64 of the last row's (sort value, id)
// counts: a separate query ['transactions','count',{filter}] with a longer staleTime; shown as "about 1.2M" when expensive (an estimate from stats)
the break
  1. At 1M rows, v1's fetch is 200 MB (15 s on fibre; the heap at 60%); at 2M the tab dies. The user sees a spinner that lasts, then a memory warning. The number is rows × bytes; nothing in v1's code is wrong.
v2: the server holds the data
  1. The client sends filter, sort and a cursor; receives one page (50 rows, ~10 KB) and the next cursor. Round trip ~100 ms on fibre.
  2. Keyset (cursor) pagination, not offset: O(page) at any depth and stable under inserts, which matters on a live table. "Jump to page N" is given up; "jump to date" becomes a filter. Previous pages via a cursor stack.
  3. The server needs a validated filter-to-WHERE mapping (an allowlist of columns and operators), a composite index per sort order, and a cursor encoding of (sort value, id).
  4. Counts are a separate, cacheable query; large counts are estimates ("about 1.2M") because exact COUNT over 10M with a filter is itself slow.
  5. The client's state shrinks to filter, sort, cursor stack and one page in the query cache, keyed by all three; keepPreviousData keeps the old page visible during a fetch; startTransition on filter changes keeps the input responsive (the React course part 5).
  6. Detail: the row is in the page; the panel reads it; a separate query if the detail needs more fields than the table (a second ~50 ms).
what v2 cannot do, and the sentence
  1. It cannot select rows it has not fetched, sum across pages, select all, or export client-side beyond the page. Each is a later round.
  2. v2 buys 10M rows and pays: +100 ms per filter or sort (latency); a query protocol, indexes, cursors (complexity, money); no cross-page client operations (capability); 30 s staleness (consistency). Right at 1M; wrong at 10k.
V2: THE SERVER HOLDS THE DATA, THE CLIENT HOLDS A WINDOW
offset versus cursor, and what each interaction costs now
swipe the figure sideways, or tap expand for full screen
1/6
the protocol
The protocol: GET /transactions?filter=…&sort=amount:desc&cursor=…&limit=50. The server translates filter to a WHERE, sort to an ORDER BY on an indexed column, cursor to a keyset condition (amount < $last AND (amount < $last OR id < $lastId)), limit to LIMIT 51 (one extra to know if there is a next page). Response: 50 rows (~10 KB) plus the next cursor. ~80 ms server + ~20 ms network on fibre.
9

Round three: one transaction, four views

The table grew a detail panel, a customer-history view and a "flagged" list, each its own query. Marking a transaction reviewed updates the query that was mutated and leaves the other three stale until they happen to refetch. The number that broke is views per entity; the fix is to make the entity, not the response, the unit of the cache.

two designs
  1. A: query-key invalidation. After the mutation, invalidate every key that could contain the entity (["transactions", *], ["customer", id, "transactions"], ["flagged"], ["transaction", id]), or patch them with setQueryData where the response carries the entity. Simple; the invalidation list is a liability that rots as views are added.
  2. B: normalisation. Responses are split into entities by id and views as id lists; components select entities by id; one update reaches every view. normalizr with a store; Apollo and Relay for GraphQL; RTK Query's entity adapters; TanStack Query with a manual normalised layer. The schema is the price; the entity graph replaces the list.
  3. Choosing: a few entity types with clear owners → A with a disciplined list and tests that mutate and check every view. A graph of types referencing each other (transactions, customers, accounts, flags, reviewers) → B.
the mechanics that matter
  1. Structural sharing (the React course part 8) so a refetch that returns the same entity keeps its reference and the row does not re-render.
  2. Entity memory: everything seen this session accumulates; bound it (gcTime for A; an LRU or reference counting for B) or a multi-hour ops session grows to the heap (the JS course part 5's leak shapes).
  3. Optimistic "mark reviewed" (part 8): edit the entity before the response; roll back on failure. With B it is one write; with A it is a patch per key.
the sentence
  1. v3 buys cross-view consistency and pays: a schema and selectors (B) or an invalidation list and refetches (A) (complexity; latency for A); entity memory bounded by policy (bytes).
V3: NORMALISED SERVER STATE
one entity, many views, one update
swipe the figure sideways, or tap expand for full screen
1/6
four views
Four views of the same transaction t_42: the main table (page 3), the detail panel (open on t_42), the customer history (customer c_7, which includes t_42), the flagged list (t_42 is flagged). v2: four queries, four copies of t_42 in four responses.
10

Round four: select all, export everything

Ops filters to 1.2M pending transactions and wants to mark them reviewed, except two, and export the lot. The client has fifty rows. The number that broke is the size of a selection relative to the page; the fix is to make selection a description the server can evaluate, and to make anything over a page a job.

v4
  1. Selection as a predicate: { mode: 'all', filter, except: [ids] } or { mode: 'some', ids }. The checkbox for a row is a function of the predicate and the row. The count is a count query minus exceptions. Changing the filter re-bases or clears the selection, explicitly.
  2. Bulk actions as jobs: POST /bulk { action, selection }; the server resolves the predicate with the table's own WHERE, applies in idempotent batches, and returns a job id; the client shows progress (polling, or a subscription from M8) and survives a reload (job id in the URL or a jobs list).
  3. Export as a job: the predicate plus columns and format; a server job streams the query to object storage and returns a signed URL; the client shows progress then a link; long ones email. Client-side CSV remains for under ~50k rows if the fast path is worth a second code path.
  4. The window: the predicate is evaluated at apply time; rows may have changed since "select all". Mitigate with a result-set snapshot id (frozen for N minutes), a confirmation showing the count at apply time, and an undo window. A bulk action on the wrong rows is the incident the questions flagged.
the sentence
  1. v4 buys bulk actions and exports at 10M and pays: a predicate model and a job system with progress and persistence (complexity, money); a t0/t1 window on the predicate (consistency; mitigated); and two export paths if the small-set fast path is kept (complexity).
the shift
From this round the frontend design is also a backend design: the predicate is a contract both sides implement identically. The table's WHERE and the bulk action's WHERE must be the same code, or "select all" acts on a different set than the one shown.
V4: SELECTION AS A PREDICATE, EXPORT AS A JOB
acting on rows the client has never seen
swipe the figure sideways, or tap expand for full screen
1/6
the ask
The user filters to "status = pending, amount > 100k" (1.2M rows), clicks "select all", then unchecks two rows on the visible page, then "mark reviewed". v2 cannot express this: it has 50 rows in memory. The selection must be a description, not a set.
11

Round five: fresh, live, and hours-long sessions

code
// v5: freshness without jumping the user's page
// "new rows above" indicator: poll (or subscribe, M8) to a count of rows newer than the page's newest, under the same filter
const { data: newer } = useQuery({ queryKey: ['transactions', 'newer', { filter, since: page.newestAt }], queryFn: …, refetchInterval: 30_000 })
// render: newer > 0 && <button onClick={() => { setCursor(null); scrollTop() }}>{newer} new transactions</button>
// the page itself does not change under the user; new data is an offer. on focus: refetch the current page only (same cursor) so edits to visible rows appear.
// row-level updates (a status changed by someone else): with normalisation (v3), a subscription that delivers entity patches updates the row in place;
//   without it, the focus refetch covers it within the freshness requirement
// what not to do: refetchInterval on the page query itself with a cursor: rows shift under the user's pointer; a click lands on the wrong row
the numbers that break next
  1. Freshness at tens of inserts per second: a 30 s staleTime shows a page up to 30 s old, within the requirement, but the ops lead wants "new ones" visible. Polling the page query would shift rows under the pointer. v5: a "new rows above" count under the same filter, polled or subscribed; the page is a snapshot; new data is an offer.
  2. Row-level changes by other users: with v3's normalisation and a subscription delivering entity patches (M8), a row updates in place; without, the focus refetch covers it within a minute.
  3. Multi-hour sessions: the entity cache grows; subscriptions accumulate; a detail panel opened 400 times leaves 400 closures if cleanup is missed. v5 bounds the cache (gcTime, LRU), audits unsubscribe on unmount (the React course part 11), and measures heap over a simulated shift (the JS course part 5's three-snapshot method). A memory budget (under 300 MB after 8 hours) becomes a non-functional requirement with a test.
  4. Column growth: the next quarter adds eight columns and two filters. Each filter needs an index; each sortable column needs a composite index; the allowlist grows. v5 makes the filter schema data-driven (a column registry the server and client share) so adding a column is configuration, not a release of both.
the sentence, and the stop
  1. v5 buys sub-minute freshness without disruption, stable multi-hour sessions, and cheap column growth; pays: a count poll or a subscription (money, complexity), a memory budget test (complexity), and a shared column registry (complexity).
  2. Stop here: the numbers in the brief are met at 10M with margin. The remaining asks (saved views, per-user layouts, a timeline chart) are features, not scaling rounds; the chart is M4.
12

The whole board, and the exercise

RoundThe numberThe breakThe designPaid in
v110k rows, 200 users, office fibre(none yet)Fetch all; filter in memory; virtualised rows; client CSVNothing; a known ceiling (~150k rows) written in the file
v21M to 10M rowsrows × bytes × 3 exceeds the heap; fetch exceeds patienceServer-side filter/sort; keyset pagination; one page in the cache+100 ms per filter; protocol, indexes, cursors; no cross-page ops; 30 s staleness
v34 views per entityA mutation updates one view; three go staleInvalidation lists (A) or normalised entities (B)A list or a schema; refetch latency (A); entity memory (B)
v41.2M-row selections; full exportsSelection cannot be a set of fetched rowsSelection as a predicate; bulk and export as jobs with progressPredicate + job system; a t0/t1 window (mitigated); two export paths
v5Tens of inserts/s; 8-hour sessions; 8 new columnsPage shifts under the pointer; heap growth; index and allowlist churn"New rows above" offer; bounded caches with a memory test; a shared column registryA poll or subscription; a test; configuration discipline
what the sequence teaches
  1. Memory was the first wall, and it was arithmetic. Every data-intensive design should start by computing rows × bytes against the heap.
  2. Moving data to the server moves capabilities with it, and each capability comes back as a predicate, a job, or a subscription, each with a cost.
  3. Consistency across views is a structural choice (invalidation versus normalisation) that is cheap to make early and expensive to retrofit.
  4. Long sessions make memory a requirement with a number and a test, not a hope.
the exercise
Take a data-heavy screen you own. Count its rows today and in two years; compute rows × bytes × 3; find which round it is in; write the sentence for the next round. If the answer is "v1, and the wall is eighteen months out", write the wall in the file and stop. That is the discipline.