Part 2 · 7 chapters · ~50 min

M2: A Compute-Intensive Web App

A bank reconciliation tool from five thousand lines to five million, in five rounds: the algorithm on the main thread and the freeze, a worker with columnar buffers crossing by transfer, a job model with a pool and cooperative cancellation, the candidate cut and WASM for the fuzzy loop, and memory as a per-device budget with an honest server fallback.

13

The brief and the questions

the brief

"Finance uploads two CSVs, the bank statement and our ledger, and the tool matches them: exact matches on amount and date, then fuzzy matches on the reference text for what is left. It shows the matched pairs and the unmatched lines, they fix a few by hand, and export. Our biggest client has about fifty thousand lines a month. It needs to run in the browser; the files cannot leave the laptop for one client."

the questions, and the answers
  1. How many lines, largest file, two years out? 50k typical now; one prospect has 500k a month; a 5M-line year-end file is plausible.
  2. What is the per-item work? Exact match: a hash lookup (microseconds). Fuzzy: an edit distance over two 30-character strings (~2 ms in JS per pair, and the candidate set per line matters: all-pairs is n²).
  3. What devices? Finance laptops (8 GB, four cores, Chrome). One client uses an iPad.
  4. What must stay responsive during the run? The page: they filter and scroll the partial results while it runs. Navigation away should not lose the job silently.
  5. What is acceptable for the run itself? "A progress bar and a coffee": minutes are fine; a frozen tab is not; a killed tab is a lost upload.
  6. Cancel? Yes; they often re-upload a corrected file.
  7. Does the data have to stay on the device? For one client, yes. For the rest, a server path is acceptable if the client path cannot cope.
  8. Memory? The tab must survive a day of uploads without a reload.
the requirements, with numbers
  1. FR: upload two CSVs; exact match; fuzzy match for the remainder; show pairs and unmatched; manual fix; export; cancel; progress.
  2. NFR: 50k lines in under 10 s; 500k in minutes with progress; 5M as the ceiling (server path allowed except for the on-device client); page responsive throughout (INP under 200 ms during a run); jobs survive navigation within the app; memory stable across a day; works on a four-core laptop and degrades honestly on an iPad.
the arithmetic in the brief
50k × 16 µs (exact) = 0.8 s on the main thread: already a frozen second. 500k lines with 10% unmatched and 100 fuzzy candidates each = 5M fuzzy compares × 2 ms = 10,000 s. The brief said "in the browser" and "fast"; the questions said the fuzzy pass is the design, and the candidate set is the first number to cut.
14

v1: a loop on the main thread, and the freeze

code
// v1: parse with a CSV library into objects, match in a loop, set state. correct; wrong thread
async function onUpload(bank: File, ledger: File) {
  setStatus('matching…')                                   // never paints: the loop below is in the same task
  const a = parseCsv(await bank.text()), b = parseCsv(await ledger.text())   // 500k objects each: ~600 MB as objects
  const byKey = new Map(b.map(r => [r.amount + '|' + r.date, r]))
  const pairs = [], rest = []
  for (const r of a) { const m = byKey.get(r.amount + '|' + r.date); m ? pairs.push([r, m]) : rest.push(r) }   // 500k × ~16 µs = 8 s
  for (const r of rest) { const m = bestFuzzy(r, b) ; … }   // n × m edit distances: hours
  setResult({ pairs, rest })
}
why v1 is where to start, and what it taught
  1. It is the algorithm, written once, testable in Node with fixtures. The matching logic (keys, tolerance on dates, the fuzzy scorer) is the valuable part and v1 proves it on 5k lines in 40 ms.
  2. Its placement is wrong at the first real number. At 50k the exact pass is 400 ms of frozen tab; the "matching…" status never paints because the state change and the loop are one task (the browser course part 3; the React course part 10's long-task pathology). At 500k it is 8 s and Chrome offers to kill the page.
  3. The fuzzy pass is a different magnitude. All-pairs is n × m; even 50k × 5k unmatched is 250M edit distances. No thread choice saves that; the candidate set must shrink first (a length filter, a shared-token index, a date window: the algorithms course part 9). v1 made it visible by never finishing.
the three placements
  1. Inline for work under ~50 ms: a worker round trip and a copy would cost more.
  2. Chunked with yields (scheduler.yield(), or setTimeout(0) with its 4 ms clamp) for 50 to ~500 ms of naturally iterable work: frames paint, but the thread is still mostly busy and the work dies with the page.
  3. A worker for anything longer, or anything that must survive interaction: the same seconds, on a thread that cannot block input.
V1 ON THE MAIN THREAD
one long task, and everything it stops
swipe the figure sideways, or tap expand for full screen
1/6
50k: one task
The timeline at 50k lines: the click lands, the loop runs for 400 ms, a frame paints with the result. One long task of 400 ms; INP 400 ms. Tolerable once, noticed every time. The spinner the code set before the loop never painted: the style change and the loop are in the same task; paint comes after.
15

Round two: a worker, and what crosses

code
// v2: the matching job in a worker, columnar data transferred in, indices transferred out, cooperative cancellation
// main thread
const worker = new Worker(new URL('./match.worker.ts', import.meta.url), { type: 'module' })
function runMatch(cols: Columns, onProgress: (p: number) => void, signal: AbortSignal) {
  return new Promise<MatchResult>((resolve, reject) => {
    const id = crypto.randomUUID()
    worker.onmessage = ({ data }) => {
      if (data.id !== id) return
      if (data.progress != null) onProgress(data.progress)
      else if (data.done) resolve({ a: data.a, b: data.b })          // Uint32Array indices, transferred back
      else if (data.cancelled) reject(new DOMException('cancelled', 'AbortError'))
    }
    signal.addEventListener('abort', () => worker.postMessage({ id, cancel: true }))
    worker.postMessage({ id, cols }, [cols.amount.buffer, cols.date.buffer, cols.refOff.buffer, cols.pool.buffer])   // transfer: O(1); cols is now detached here
  })
}
// worker
let cancelled = new Set<string>()
self.onmessage = ({ data }) => {
  if (data.cancel) { cancelled.add(data.id); return }
  const { id, cols } = data; const n = cols.amount.length; const a: number[] = [], b: number[] = []
  const index = buildHashIndex(cols)                      // amount+date → candidate rows: O(n)
  let lastPost = 0
  for (let r = 0; r < n; r++) {
    if ((r & 0x3fff) === 0) {                             // every 16k rows (~1 ms): check the flag, maybe post progress
      if (cancelled.has(id)) { cancelled.delete(id); self.postMessage({ id, cancelled: true }); return }
      const now = performance.now(); if (now - lastPost > 100) { self.postMessage({ id, progress: r / n }); lastPost = now }
    }
    const m = index.find(cols, r); if (m >= 0) { a.push(r); b.push(m) }
  }
  const A = Uint32Array.from(a), B = Uint32Array.from(b)
  self.postMessage({ id, done: true, a: A, b: B }, [A.buffer, B.buffer])
}
v2
  1. Parse in the worker from File.stream(): the main thread never holds the lines. Parse straight into columnar typed arrays (amount: Float64Array, date: Int32Array as days, id: Uint32Array, references as offsets into one UTF-8 pool): 20 bytes a row plus strings instead of ~600 bytes as objects. 500k lines is ~30 MB, not 300.
  2. Transfer, do not copy: buffers cross by postMessage(msg, [buffers]) in O(1); the sender's view detaches. Progress and commands are small messages (copied, cheap). Results come back as two Uint32Arrays of indices; the display joins them to rows lazily for the visible thirty.
  3. Cooperative cancellation: the loop checks a flag every 16k rows (~1 ms); an AbortSignal on the main side posts the cancel. The alternative, worker.terminate(), works but discards the warm worker and anything else it holds.
  4. Progress at most every 100 ms, throttled in the worker where the clock is, not on the main thread where the messages land.
  5. The exact pass is a hash index on (amount, date) built in O(n); the fuzzy pass runs only on the remainder, against candidates from a token index with a date window, and is a separate job with its own progress (the user sees exact results while fuzzy runs).
the sentence
  1. v2 buys a responsive page during an 8 s (or 80 s) job and pays: a columnar data format the code reads like C (complexity); no DOM or app state in the worker, so results are asynchronous and the UI is a state machine over job messages (complexity); a second file to build and debug (complexity); and nothing in bytes or latency: the job is the same length.
the format decides the thread model
The worker was the obvious move; the columnar format is the one that made it cheap. With object-shaped data, the structured clone into the worker is a 300 ms long task on the thread the worker was meant to protect, and two heaps hold the data. Decide the format first.
WHAT CROSSES THE BOUNDARY
copy, transfer, share: the three ways data reaches a worker
swipe the figure sideways, or tap expand for full screen
1/6
copy
Copy (structured clone): postMessage({lines}) walks 500k objects, serialises and deserialises: ~300 ms on the sender, ~300 ms on the receiver, and both sides now hold the data. For small messages (progress: {done: 41200}) it is the right default; for the dataset it is a long task on the thread the worker was meant to protect.
16

Round three: many jobs, a pool, and stopping

The second feature arrived: re-match after a manual fix, export, validation on upload. Each spawned its own worker; a double-click started two matches; export and match ran together and both crawled; navigating to the results page while a match ran left it orphaned. The number that broke is jobs per session; the fix is a job model.

v3
  1. A job is data: id, kind, input key, priority, progress, state, abort token. The UI lists running jobs from the same store that schedules them; a reload shows "a match was running" because the job id persists.
  2. A queue ordered by priority then arrival; one job per (kind, input key): a re-run on unchanged input joins the running one. Interactive work (validate the row the user is editing) outranks batch (export).
  3. A pool of hardwareConcurrency − 1 workers, capped at four by memory (each holds its working set), one warm at startup (creation and script load is 30 to 100 ms). Jobs are assigned as workers free.
  4. Cancellation through the token to whichever worker holds the job; a cancelled worker is reused. pagehide cancels long jobs and persists their position so navigation within the app can resume them.
  5. Splitting one job across the pool when the partition is natural: the hash join partitions by key, each worker matches a shard, results concatenate. 8 s on one worker is ~2.5 s on four; the merge and shard imbalance eat the rest (Amdahl). Sorting a single list across workers is its own merge problem and rarely worth it here.
the sentence
  1. v3 buys predictable concurrency, cancellation and 3× on partitionable jobs and pays: a scheduler and a job store (complexity); N working sets in memory (bytes); a partition step and a merge (complexity, and a ceiling on the speed-up).
V3: A JOB MODEL
queue, priority, progress, cancellation, and a pool
swipe the figure sideways, or tap expand for full screen
1/6
without a model
Without a model: each feature creates its own worker and posts to it; two matches start when the user double-clicks; an export runs while a match is halfway and both are slow; nothing can be cancelled; navigating away leaves the worker running until the page unloads.
17

Round four: the fuzzy pass and WASM

The exact pass is fast now. The fuzzy pass, even with candidates cut to ~100 per line by the token index, is 50k unmatched × 100 × 2 ms = 10,000 s on one worker. The algorithm has been given its best JS (typed arrays, no allocation, a length pre-filter, bitap for short patterns: the algorithms course part 9) and the profile is 90% in one function. That is the shape where WASM is a lever.

v4
  1. Profile first. WASM pays on an isolated numeric or byte-level hot loop where the JIT has plateaued (monomorphic, typed, allocation-free, still slow). It pays nothing on DOM, JSON or allocation-heavy string code.
  2. One coarse call per shard: fuzzy_match_shard(ptr, len) loops inside WASM; per-pair calls would add 50 ms (fine), per-character calls seconds (not).
  3. Data in WASM memory: allocate the columnar buffers as views on wasm.memory.buffer so nothing copies; or accept one 30 MB copy per job (~5 ms). Re-create views after memory.grow().
  4. SIMD (simd128) for the edit-distance inner loop; feature-detect and ship a scalar build too.
  5. The result: 0.15 ms per pair instead of 2 ms: 10,000 s becomes ~750 s on one worker, ~200 s on four. Still a coffee; no longer a day.
the sentence
  1. v4 buys 10× on the hot loop and pays: a second language and toolchain in the build, a 100 to 500 KB binary (streaming compile, cached), awkward debugging, and a boundary of integers and pointers that someone must maintain (complexity, money). Keep the surface to one or two functions over buffers.
the bigger lever was the candidate set
The token index cut 5,000 candidates per line to 100 before any language change: 50×. WASM gave 10×. The algorithmic cut is always the first round; WASM is the round after the algorithm is right.
V4: WASM FOR THE HOT LOOP
when the JIT is the ceiling, and what crossing into WASM costs
swipe the figure sideways, or tap expand for full screen
1/6
the profile
The profile: 90% of the job is one function (the fuzzy compare); the JIT has done what it can (monomorphic, typed arrays, no allocation in the loop: the JS course part 2). The remaining ceiling is bounds checks, the lack of SIMD in plain JS, and GC. This is the shape WASM pays off on; a loop that is 30% of a job with allocation everywhere is not.
18

Round five: memory, devices, and the honest fallback

code
// v5: memory as a budget. the tab has ~1-2 GB on a laptop, ~300 MB on a phone; a worker's heap counts against it
// 1. know the working set per job before accepting it
const bytesPerRow = 20 /* columns */ + 24 /* avg ref string */
function admit(job: Job) {
  const need = job.rows * bytesPerRow * 2             // input + index
  if (used + need > budget) return queueUntilFree(job) // or: reject with "file too large for this device; use the server path"
  used += need; return start(job)
}
// 2. the budget is per device, not a constant: navigator.deviceMemory (GB, coarse), performance.memory (Chrome only), and the honest fallback: a probe
const budget = (navigator.deviceMemory ?? 4) >= 8 ? 600e6 : 200e6
// 3. release: a finished job's buffers are dropped (worker-side: set to null; main-side: never held). a pool worker that has grown keeps its heap;
//    recycle a worker after N jobs or when its reported heap (performance.memory in the worker) exceeds a line
// 4. the server path: past the device budget (a 5M-line file on a phone) the same job runs server-side with the same progress UI; the client decides by arithmetic, not by trying and dying
// 5. the test: run 20 jobs back to back in CI (Playwright + CDP Memory.getHeapUsage) and assert heap after GC is within 10% of the first run: the JS course part 5
the numbers that break next
  1. Memory across a day: each job's buffers are freed when it finishes (the worker drops its references; the main thread never held them). But a worker that grew to 400 MB for a big file keeps its heap reserved; recycle a worker after N jobs or past a heap line. The test: twenty jobs in CI, heap after GC within 10% of the first (the JS course part 5).
  2. Devices: the iPad has ~300 MB for the tab and two usable cores. A 5M-line file is 300 MB columnar plus the index: it does not fit. v5 computes the working set from the row count before starting, against a per-device budget (deviceMemory, coarse; a probe when absent), and routes to the server path with the same progress UI when it does not fit. The on-device client gets a clear "too large for this device" instead of a crash.
  3. The server path: the same job model with a job id from the server and progress over SSE (M8); the client code is the same store, a different executor. For the client that cannot upload, the ceiling is honest: 1M lines on their laptop, stated in the product.
  4. Background tabs: workers keep running when the tab is hidden (timers throttle; workers do not), which is what finance wants: switch to email, come back to a finished match. But a hidden tab is the first to be discarded under memory pressure (the browser course part 10): persist job state so a discarded-and-restored tab can resume or at least report.
the sentence, and the stop
  1. v5 buys stable memory, honest device limits and a server fallback; pays: admission control and recycling (complexity), a device budget that is a guess refined by probing (complexity), a second executor (money, complexity).
  2. Stop: 500k lines in under a minute on a laptop with a responsive page, 5M via the server or an honest limit, cancellation, progress, a day without a reload. The brief's numbers are met with margin; the next asks (match rules editor, scheduled runs) are features.
19

The whole board, and the exercise

RoundThe numberThe breakThe designPaid in
v15k lines, 40 ms50k: a 400 ms freeze; 500k: killed tabThe algorithm, on the main threadNothing; it proved the matching logic
v2500k lines, 8 sMain thread cannot host secondsWorker; columnar buffers transferred; indices back; cooperative cancel; throttled progressA C-like data format; async results; a second file
v3Several jobs per sessionDuplicate work, contention, orphaned workersJob store, priority queue, pool of N−1 capped at 4, split by partitionA scheduler; N working sets; a merge ceiling
v45M fuzzy compares at 2 msJS plateau on an isolated hot loopCandidate index first (50×); then WASM + SIMD for the compare (10×)A toolchain and a boundary to maintain
v5A day of uploads; an iPad; a 5M-line fileHeap growth; device limits; discardAdmission by arithmetic; recycling; per-device budget; server executor; persisted job stateA second executor; a guessed budget; tests
what the sequence teaches
  1. A worker moves time; it does not remove it. The seconds are the same; the thread is different. Removing time is the algorithm's job (the candidate cut) and WASM's (the hot loop).
  2. The data format is the thread model. Columnar buffers made transfer, WASM and memory budgets possible; object-shaped data would have made each a copy.
  3. Everything long is a job: id, progress, cancel, persist. The UI is a state machine over job messages; the executor (worker, pool, server) is swappable behind it.
  4. Memory is a budget per device, computed before starting, with an honest fallback when the arithmetic says no.
the exercise
Find the longest synchronous function in a product you own (DevTools Performance, sort by self time). Multiply its per-item cost by the largest input the business expects in two years. If the answer is over 500 ms, decide the data format, then the thread; if it is over a minute, decide the algorithm first.