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.
The brief and the questions
"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."
- 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.
- 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²).
- What devices? Finance laptops (8 GB, four cores, Chrome). One client uses an iPad.
- 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.
- 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.
- Cancel? Yes; they often re-upload a corrected file.
- 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.
- Memory? The tab must survive a day of uploads without a reload.
- FR: upload two CSVs; exact match; fuzzy match for the remainder; show pairs and unmatched; manual fix; export; cancel; progress.
- 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.
v1: a loop on the main thread, and the freeze
// 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 })
}- 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.
- 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.
- 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.
- Inline for work under ~50 ms: a worker round trip and a copy would cost more.
- Chunked with yields (
scheduler.yield(), orsetTimeout(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. - A worker for anything longer, or anything that must survive interaction: the same seconds, on a thread that cannot block input.
Round two: a worker, and what crosses
// 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])
}- 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. - 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. - 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. - Progress at most every 100 ms, throttled in the worker where the clock is, not on the main thread where the messages land.
- 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).
- 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.
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.
- 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.
- 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).
- A pool of
hardwareConcurrency − 1workers, 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. - Cancellation through the token to whichever worker holds the job; a cancelled worker is reused.
pagehidecancels long jobs and persists their position so navigation within the app can resume them. - 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.
- 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).
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.
- 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.
- 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). - Data in WASM memory: allocate the columnar buffers as views on
wasm.memory.bufferso nothing copies; or accept one 30 MB copy per job (~5 ms). Re-create views aftermemory.grow(). - SIMD (
simd128) for the edit-distance inner loop; feature-detect and ship a scalar build too. - 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.
- 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.
Round five: memory, devices, and the honest fallback
// 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- 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).
- 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. - 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.
- 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.
- 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).
- 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.
The whole board, and the exercise
| Round | The number | The break | The design | Paid in |
|---|---|---|---|---|
| v1 | 5k lines, 40 ms | 50k: a 400 ms freeze; 500k: killed tab | The algorithm, on the main thread | Nothing; it proved the matching logic |
| v2 | 500k lines, 8 s | Main thread cannot host seconds | Worker; columnar buffers transferred; indices back; cooperative cancel; throttled progress | A C-like data format; async results; a second file |
| v3 | Several jobs per session | Duplicate work, contention, orphaned workers | Job store, priority queue, pool of N−1 capped at 4, split by partition | A scheduler; N working sets; a merge ceiling |
| v4 | 5M fuzzy compares at 2 ms | JS plateau on an isolated hot loop | Candidate index first (50×); then WASM + SIMD for the compare (10×) | A toolchain and a boundary to maintain |
| v5 | A day of uploads; an iPad; a 5M-line file | Heap growth; device limits; discard | Admission by arithmetic; recycling; per-device budget; server executor; persisted job state | A second executor; a guessed budget; tests |
- 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).
- 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.
- 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.
- Memory is a budget per device, computed before starting, with an honest fallback when the arithmetic says no.