Files
homarr/docs/perf/CLIENT-PERFORMANCE.md
T
Thomas Camlong c73b139dbd docs(perf): split the findings into per-subject markdown files
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.
2026-08-10 19:08:20 +02:00

101 lines
4.4 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.