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

6.0 KiB
Raw Blame History

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.