PHASE2-FINDINGS.md had grown past 1,200 lines mixing server memory, client rendering, methodology and a long list of rejected experiments, which made it unusable as a reference. Split under docs/perf/: README.md headline results and the change-by-change ratio table SERVER-MEMORY.md what the container's RAM is made of, layer by layer CLIENT-PERFORMANCE.md dashboard cost in Chrome: DOM, fibers, images, paint REJECTED.md ideas implemented, measured and abandoned, with numbers METHODOLOGY.md the tools, and the measurement traps that gave wrong answers PHASE2-FINDINGS.md is now an index so existing links keep working. METHODOLOGY.md is the part worth keeping: every wrong number in this investigation came from an instrument rather than the application, and each trap is written down - snapshots cannot explain peaks, V8 truncates snapshot strings to ~1 KiB, snapshot objects are not live handles, measuring without a forced GC measures V8's growth, taking a CDP snapshot added 134 MiB to the process being measured, per-component React attribution silently returns nothing on a production build, a fixed port can measure a leftover container, and a server-side change that moves a client-side metric means the workload changed.
4.4 KiB
Dashboard performance in Chrome
Measured on the busiest board of a restored real backup: 28 items, 16 widget kinds.
Where the board's cost actually is
scripts/benchmarks/measure-widget-cost.mts attributes DOM nodes and React fibers to each widget by
walking the fiber subtree hanging off its DOM node. Totals do not tell you what to fix; this does.
Board totals: 9,293 DOM nodes, 8,165 event listeners, 86.3 MiB JS heap, 28 widgets.
| nodes | each | fibers | each | n | widget kind |
|---|---|---|---|---|---|
| 2,951 | 2,951 | 8,387 | 8,387 | 1 | mediaReleases |
| 1,156 | 1,156 | 3,231 | 3,231 | 1 | beszelSystemStats |
| 451 | 451 | 1,286 | 1,286 | 1 | beszelSystemTable |
| 393 | 393 | 1,204 | 1,204 | 1 | mediaRequests-requestList |
| 313 | 313 | 669 | 669 | 1 | beszelSystemGrid |
| 265 | 265 | 900 | 900 | 1 | downloads |
| 251 | 251 | 1,195 | 1,195 | 1 | calendar |
| 177 | 177 | 390 | 390 | 1 | dockerContainers |
| 88 | 8 | 528 | 48 | 11 | app |
| 62 | 21 | 297 | 99 | 3 | umami |
One widget is 44% of the board's DOM. mediaReleases contains 1,935 Mantine Box
components on its own.
Why: it renders 13× more than the tile can show
Measured directly on that widget:
| tile height | 539 px |
| content height | 7,206 px |
| cards rendered | 80 |
| cards visible | 7 |
| nodes per card | 34 |
<img> elements |
80 |
<svg> / <path> |
160 / 560 |
The component is releases.map(...) with no cap — every release the integrations return becomes
a card. So 73 of 80 cards are entirely off-screen, costing ~2,480 DOM nodes, ~7,600 fibers, and a
poster plus a CSS backdrop image each. Decoded bitmaps are far larger in memory than their files, and
this is the expensive part.
The fix
content-visibility: auto with contain-intrinsic-size on each card, plus loading="lazy" on the
poster. The browser then skips layout, paint, raster and background-image fetch for off-screen
cards. Nothing leaves the DOM, so scrolling and in-page search are unchanged; the intrinsic size stops
the scrollbar jumping.
Three repeats per side, identical restored data:
| metric | before | after | |
|---|---|---|---|
| image requests | 135, 135, 135 | 55, 55, 56 | −59% |
| image bytes (declared) | 12.27 MiB | 8.41 MiB | −31% |
| layout objects | 8346, 7815, 7815 | 5117, 4696, 4636 | −41% |
Not established, stated because a single run suggested otherwise: JS heap did not clearly improve
— 46–88 MiB before against 74–86 MiB after, a 2× spread on the before side. This is expected, since
decoded image memory lives outside JSHeapUsedSize, so the saving from 81 avoided image fetches
is invisible to that metric. Task duration and time-to-settled overlap and support no conclusion.
Idle churn is widget-driven, and legitimate
An idle board commits 73–96 times per minute, which earlier rounds attributed to the App Router re-rendering once a second. That was wrong — see the correction in REJECTED.md and METHODOLOGY.md.
| board | items | idle commits/min |
|---|---|---|
hey |
0 | 0.0 |
footbol |
3 | 0.0 |
default |
28 | 76.0 |
A 3-item board commits zero times per minute. Attributed by DOM mutation
(measure-widget-churn.mts), three Beszel widgets account for 82% of it — but the real rate is
1.22 React commits per second for the whole board at 13.3 DOM mutations per commit. That is
three live-monitoring widgets refreshing about once a second, which is what they are for. Not a
defect.
Earlier client wins
| change | effect |
|---|---|
| one shared board context menu instead of one per item | −23% listeners, −31% fibers |
useTimeAgo comparing before setState |
correctness fix; idle commits unchanged, recorded as not a measured win |
What remains on the client
beszelSystemStatsat 1,156 nodes / 3,231 fibers is the next largest single widget. Its cost is chart SVG (<g>×317,<path>×98), which is harder to reduce without changing what it draws.documentsreads 18–32 depending on the run. Not yet investigated; each document carries its own overhead.- The same map-everything pattern appears in
mediaRequests-requestList(393 nodes),beszelSystemTable(451) anddownloads(265). Smaller instances of the widget fixed above.