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.
101 lines
4.4 KiB
Markdown
101 lines
4.4 KiB
Markdown
# 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](./REJECTED.md#coalescing-beszel-subscription-updates--measured-exactly-zero) and
|
||
[METHODOLOGY.md](./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
|
||
|
||
- **`beszelSystemStats` at 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.
|
||
- **`documents` reads 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) and `downloads` (265). Smaller instances of the widget fixed above.
|