Files
homarr/docs/perf/CLIENT-PERFORMANCE.md
T
Thomas Camlong 37dead1690 docs(perf): verify the off-screen changes, and record two instruments that lied
The off-screen work was committed unmeasured; this closes that debt. Measured on two
containers restored from identical data:

  image requests   138, 135  ->  22, 22   (-84%)
  image bytes        12.27 MiB -> 0.03 MiB
  layout objects  11611, 7836  ->  3694, 3705  (-53%)

A 99.8% drop in image bytes is not believable, so it was checked instead of reported. Two
checks were bad instruments before one worked:

  - Counting decoded images via getBoundingClientRect() reported 80/80 loaded in BOTH arms.
    Reading geometry forces layout, which forces content-visibility subtrees to render, which
    triggers the very image loads being tested for. The instrument caused the result.
  - Screenshots came back byte-identical in both arms, because this environment shows an
    onboarding overlay and media-requests renders blank with unreachable integrations. Good
    evidence of no visual difference; useless as evidence that images render.
  - Counting img.complete && naturalWidth > 0 reads no geometry and gave the answer: 80/80
    decoded in both arms, 138 responses before against 25 after.

So nothing is visually broken. The 80 imgs are integration icons sharing URLs, deduplicated to
~25 unique fetches in both arms; the ~113 avoided requests are other widgets' off-screen
artwork, chiefly media-requests' full-size per-card backdrops, which is also where the 12 MiB
came from.

Stated limit: media-requests renders blank in this environment, so its visible-card behaviour
after the change is unverified. Media-releases is verified at 80/80 decoded.
2026-08-11 10:20:33 +02:00

137 lines
8.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.
## Verification of the off-screen changes, and one instrument that lied
The off-screen work was committed before it was measured. Verified afterwards on two containers
restored from identical data, `maxopt` against `client3`:
| metric | before | after |
| --- | --- | --- |
| image requests | 138, 135 | **22, 22** (−84%) |
| image bytes (declared) | 12.27 MiB | **0.03 MiB** |
| layout objects | 11611, 7836 | **3694, 3705** (−53%) |
**A −99.8% drop in image bytes is not believable, so it was checked rather than reported.** Three
checks, and the first two were bad instruments:
1. Counting decoded images with `getBoundingClientRect()` reported **80/80 loaded in both arms** — but
reading geometry forces layout, which forces `content-visibility` subtrees to render, which triggers
exactly the image loads the check was looking for. The instrument caused the result.
2. Screenshotting the widgets produced **byte-identical images in both arms** (3,372 bytes for
media-releases, 1,580 for media-requests), because this environment shows an onboarding overlay and
media-requests renders blank with unreachable integrations. Useful only as evidence of *no visual
difference*, not as evidence images render.
3. Counting `img.complete && img.naturalWidth > 0`, which reads no geometry, gave the answer:
| arm | images decoded | image responses |
| --- | --- | --- |
| before | **80 / 80** | 138 |
| after | **80 / 80** | **25** |
**Everything still displays.** The 80 `<img>` in media-releases are integration icons that share URLs,
so the browser deduplicates them to ~25 unique fetches in both arms; the ~113 requests avoided are
other widgets' off-screen artwork, chiefly media-requests' full-size per-card backdrops. That also
explains the byte figure: those backdrops are where the 12.27 MiB came from.
One limit worth stating: media-requests renders blank here because its integrations are unreachable, so
its *visible-card* behaviour after the change is not verified. Media-releases is, at 80/80 decoded.