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

4.4 KiB
Raw Blame History

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 and 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.