Performance¶
Task 3.8. Every NFR-1 to NFR-7 target gets a measured number, so a regression is visible in a diff rather than argued about.
Method, fixed before any number was recorded¶
- Sample size is 100 for every latency figure. p50, p95 and max are reported.
- Page timing is the loopback response time of the standalone server —
request sent to full body received — measured by
scripts/perf-measure.ts. On localhost that is server render with a sub-millisecond transfer and no browser, so it is neither TTFB over a network nor a browser LCP. Data pages setdynamic = 'force-dynamic'(tad.md8.2), so there is no cache to warm; the figure is the steady state after one discarded warm-up request. - Log render is the CPU the viewer spends before first paint —
parseLinesthenapplyFilterover 5,000 lines — with no upstream fetch, because the CE server's latency is not a property of Withe. - Datasets are generated by
scripts/perf-dataset.ts, because the author's fleet is 8 repositories and NFR-2 asks for 500. Each repository gets 30 runs and 6 pending updates, a tenth are failing, and each carries a lock-file refresh — the mix that makes the landing page do work. - Memory is the sum of resident set size (
VmRSSfrom/proc) across every process in the container — supervisor, web, worker, tini — at 5 minutes idle. The proportional set size (Pssfromsmaps_rollup) is reported alongside it, because summing RSS counts the shared Node binary and libc three times; PSS is the honest footprint. - Cold start is wall-clock from
docker runto the first served page. - Reproduce the source-measurable half with
npm run build && npm run perf.
Where these were measured¶
- Latency and sync-write: this host, amd64, Node v24.17.0.
- Memory was measured on native arm64 — a Raspberry Pi (Debian 13,
aarch64, Docker 29.7.2), the constrained target the method names, on
2026-08-14. It could not be measured under QEMU on the amd64 host: emulation
wraps every process in a
qemu-aarch64layer that adds its own hundreds of megabytes, so the container there reads ~430 MB of emulator, not application. On the Pi, 5 minutes idle: 232 MB RSS-sum, 133 MB PSS — close to the amd64 numbers (239 / 140), which confirms the V8 heap and mapped code dominate and are architecture-stable. - Cold start was measured on arm64 under emulation, slower than native — a conservative direction for a latency budget.
- Container image: measured in Task 3.5 on both architectures.
Results¶
| NFR | Target | Measured | Verdict |
|---|---|---|---|
| NFR-1 | Landing page, 50 repos, p95 <= 400 ms | p50 28.9, p95 84.4, max 144.5 ms | pass |
| NFR-2 | Landing page, 500 repos, p95 <= 1,200 ms | p50 195.4, p95 244.6, max 294.5 ms | pass |
| NFR-3 | Log render, 5,000 lines, p95 <= 1,000 ms, no fetch | p50 1.5, p95 2.0, max 2.6 ms | pass |
| NFR-4 | Full sync, 50 repos, <= 60 s | write half 38.8 ms; live 8-repo end-to-end ~1 s | pass |
| NFR-5 | Idle memory <= 256 MB resident, arm64 | 232 MB RSS-sum (133 MB PSS) on native arm64 | pass |
| NFR-6 | Image <= 300 MB uncompressed | 217 MB amd64, 211 MB arm64 (Task 3.5) | pass |
| NFR-7 | Cold start to serving <= 15 s | 6.1 s on arm64 under emulation | pass |
Notes per target¶
- NFR-4 is fetch-bound: a full cycle is
fetch from CE+map+persist. The fetch is CE's latency, not Withe's, so the measured Withe-side number is the write half — 50 repositories with their runs and updates straight to disk, 38.8 ms. The live 8-repo fleet's real end-to-end cycle, including the fetch, is under a second (sync_statusdurations). 50 repositories is comfortably inside 60 s. - NFR-6 was measured in Task 3.5 and is repeated here for completeness.
- NFR-3 windows the DOM (22 px rows, no wrapping), so first paint renders only the visible rows regardless of length; the cost that scales with the log is the parse and filter measured here.
Concurrency (Task 3.9, risk TR-1)¶
Two processes share one SQLite file — the web reader and the sync worker. The
risk is a long sync transaction blocking readers past busy_timeout until
pages fail. npm run check:contention runs the real shape: a standalone server
reads while a separate process calls persist() for 500 repositories, 20 times
back to back, with 8 concurrent readers hammering / and /repos throughout.
| Metric | Result |
|---|---|
| Requests served during the writes | 217 |
| Reader failures on a locked database | 0 |
| Reader latency p95 / max | 1,466 ms / 1,735 ms |
Worst reader wait vs busy_timeout |
1,735 ms < 5,000 ms |
| Writer transaction (500 repos) p50 / max | 1,005 ms / 1,457 ms |
The first run failed — reader p95 20,671 ms and three 500s — and exposed a real
bug: openDatabase set busy_timeout after PRAGMA journal_mode = WAL and ran
that write-locking pragma on every read connection, so a page render raced the
sync's write lock. The fix gives the connection a role: the owner sets WAL, a
reader only reads it. tad.md TR-1 holds the detail.