Files
homarr/docs/perf/CLIENT-PERFORMANCE.md
T
Thomas Camlong d2e86895f4 docs(perf): rewrite the client doc with the measured overlay results
Replaces the earlier version with what was actually measured, and closes out all 54
findings from the widget audit rather than leaving categories open:

  overlay-per-row (12)      5 fixed; 7 are functional tooltips with real labels
  index-key (11)            all safe - static text splits, or stateless rows from
                            user-configured JSONPath data with no stable id
  nested-component (2)      both fixed
  inline-style-per-row (29) left alone; a fresh object per row only defeats memoisation
                            if the child is memoised, and these are not

Measured, deterministic per-widget counts:

  mediaReleases      340 comp / 8387 fib  ->  170 / 8057
  calendar           171 comp / 1195 fib  ->   75 /  907
  beszelSystemTable   36 comp / 1286 fib  ->   18 / 1230

Records two metrics that turned out unusable - Chrome's board-level JSEventListeners read
7905 to 17114 across six runs of identical code, and its Nodes counter includes detached
nodes - and one claim withdrawn: the media-releases tooltip fix measured zero, the win for
that widget came entirely from the shared OverflowBadge fix.
2026-08-11 10:11:08 +02:00

101 lines
6.0 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.
## A systemic Mantine cost, found in five places
**Mantine overlay components mount their full machinery regardless of `disabled`.** A `Tooltip`,
`HoverCard` or `Popover` that can never open still creates a `Popover`, a `PopoverTarget` and a
`PopoverDropdown`. `disabled` suppresses *display*, not *cost* — so avoiding it means deciding before
rendering, not passing a prop.
That single fact accounts for five separate findings, three of them measured:
| widget | before | after |
| --- | --- | --- |
| **mediaReleases** | 340 components / 8,387 fibers | **170 / 8,057** (−50% components) |
| **calendar** | 171 components / 1,195 fibers | **75 / 907** (−56% components, −24% fibers) |
| **beszelSystemTable** | 36 components / 1,286 fibers | **18 / 1,230** (−50% components) |
| downloads, mediaRequests, app | unchanged | unchanged — their overlays are functional |
- **calendar** mounted a `HoverCard` per day cell. A month grid is ~42 cells and `disabled` is
`isEditMode || eventsForDate.length === 0`, so most days — and in edit mode *every* day — mounted
three components for a card that can never open. 126 of its 171 components, in a widget with only
251 DOM nodes.
- **OverflowBadge** (shared UI in `packages/ui`) mounted a `Popover` and `Popover.Dropdown` even when
nothing overflowed, which is the common case. Used by media-releases, releases, the docker table and
the widget inputs, so it repeated per row wherever a list renders it. This is where the entire
media-releases win came from.
- **beszel disk-usage** mounted a `HoverCard` per table row for systems reporting no filesystems —
it already fell back to a plain bar inside its own target, so the overlay existed to render nothing.
### Two remount bugs
A component declared inside a render body is a **new type every render**, so React unmounts and
remounts its subtree instead of updating it.
- `beszelSystemTable` declared `PercentCell` inline and used it for three columns, while the widget
re-renders ~1×/second from its live subscription — three cell subtrees per system rebuilt every
second. Hoisted and memoised.
- A drag handle was passed to a child as `handle={Handle}` while its parent re-renders every frame of
a drag, so the handle was rebuilt continuously mid-drag. Stabilised with `useMemo`.
### The audit that found them
`scripts/benchmarks/audit-widget-render.mjs` scans every widget and ranks findings by whether they sit
inside a `.map()`, since those multiply by row count. All 54 findings were triaged:
| count | pattern | outcome |
| --- | --- | --- |
| 12 | overlay-per-row | **5 fixed**; the other 7 are functional tooltips with real labels — nothing is disabled to skip, and swapping them for native `title` would change UX, not just cost |
| 11 | index-key | **all safe.** Either static text splits (`description.split("\n")`) or stateless read-only rows from user-configured JSONPath data with no stable id, where an index key is correct and avoids remounts |
| 2 | nested-component | **both fixed** |
| 29 | inline-style-per-row | **left alone.** A fresh object per row only defeats memoisation if the child is memoised, and these are not. 29 edits without a measurement is churn |
### Off-screen rendering
`mediaReleases` rendered **80 cards into a 539 px tile whose content is 7,206 px** — 7 visible, 73
off-screen, each still fetching and decoding a poster and a full-tile backdrop. Decoded bitmaps are
far larger in memory than their files, which is what made this expensive.
Fixed with `content-visibility: auto` + `contain-intrinsic-size`, plus `loading="lazy"`. Three repeats
per side:
| metric | before | after |
| --- | --- | --- |
| image requests | 135, 135, 135 | **55, 55, 56** (−59%) |
| image bytes | 12.27 MiB | **8.41 MiB** (−31%) |
| layout objects | 8346, 7815, 7815 | **5117, 4696, 4636** (−41%) |
Extracted to `offscreenRowStyle()` and applied to the other data-driven lists (rssFeed, notifications,
calendar events, media-requests). Applied to 5 of 26 `ScrollArea` widgets deliberately:
`content-visibility` costs containment work and can be wrong on self-measuring content like charts.
### Two metrics that cannot be used, and one claim withdrawn
**Chrome's board-level `JSEventListeners` is unusable here.** Across six runs of identical code it read
7905, 8000, 8166, 8179, 10360 and 17114. Its `Nodes` counter has the same problem — 23,072 on one run
against ~8,850 on the others — because it counts detached nodes awaiting collection, while
"elements under body" stayed at 6,691–6,726 throughout. Only per-widget component and fiber counts are
stable enough to compare, so those are what is reported above.
**The media-releases tooltip fix measured zero**, and is reported as such. Every release on the test
board has a description and the option is on, so all 80 tooltips are legitimately enabled and none are
skipped. The mechanism is correct; this board is not the case it helps. The −50% for that widget came
entirely from OverflowBadge.
### Idle churn is legitimate, and an earlier claim here was wrong
This description previously said ~80% of idle React commits entered at Next's `HeadManagerContext` and
that the App Router re-rendered once a second. **Both were false.** A 3-item board commits **0.0**
times per minute against 76.0 for the 28-item board — if the router re-rendered on a timer, every board
would churn. The original attribution was a tooling artefact: `fiber.actualDuration` is only populated
by a React *profiling* build, so on production every fiber fails the "did it re-render" test and the
tables come back empty, which reads as "nothing" rather than "this build cannot tell you".
Attributed properly, three Beszel widgets are 82% of idle churn — but the real rate is **1.22 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. A coalescing fix measured *exactly
zero* (55 commits both sides) and was reverted.