Sourced with the skills CLI - npx skills find "react performance", then
npx skills use affaan-m/ecc@react-performance, an adaptation of vercel-labs
react-best-practices: 70+ rules across 8 categories. Every mechanically
checkable rule was run against the widgets.
Seven already hold, and that is worth recording as much as a finding: no widget
registers a scroll listener, no numeric && can render a literal 0, the nested
array scans that exist are over single-digit collections, React.cache() is
applied, TanStack Query does the deduplication, content-visibility is on the six
lists that need it, and the two components-declared-in-render were already found
and fixed.
One rule this codebase deliberately contradicts: the skill prefers React 19
<Activity> over unmounting for tabs, and this pass did the opposite with
keepMounted={false}. The rule optimises for switching; a dashboard optimises for
mounting. Every board load mounts every widget while a user switches a widget's
inner tabs rarely, so building a hidden monitoring view on every page load buys
a faster switch nobody performs. Where state loss would show - custom-api's user
JSX, the edit modal's forms - the panels stay mounted.
Two candidates declined rather than guessed at, both recorded with reasoning:
the 20+ `data = []` defaults only destabilise while data is undefined, since
react-query returns a stable reference once loaded; and the 7 unlisted Mantine
entry points need two production builds and a first-load-JS diff before anyone
claims they help. @homarr/ui is not eligible at all despite 111 importers,
because Next's barrel optimiser only rewrites pure re-export files.
Also records the process error behind the earlier "no skills CLI" claim: the
entries in ~/.claude/skills are symlinks, which find -type f skips, so the
directory looked empty right after cat had succeeded on a file inside it.
6.9 KiB
Methodology, and the traps that produced wrong answers
Every wrong number in this investigation came from an instrument, not from the application. They are recorded here because each one looked plausible at the time, and several survived long enough to be published before being caught.
The harness
scripts/benchmarks/stress-restore.mts boots a fresh container, restores a supplied backup
through the onboarding restore UI (including typing "I understand"), signs in, opens boards
across several tabs, clicks into widgets, reloads, then soaks idle — sampling memory, CPU, browser
heap, DOM nodes and listeners at each stage.
Each run proves it is actually behind a login rather than reading a public page: it asserts a cookie-less request to the private board is denied, requires the Auth.js session cookie after sign-in, and reports whether it modified the restored data at all.
The backup is passed by path via STRESS_BACKUP_ZIP and never enters the repository — it carries
real integration secrets. .gitignore also refuses homarr-backup*.zip and loose *.sqlite.
Tools
| tool | answers |
|---|---|
attribute-memory.mts |
cgroup → processes → address-space mappings → V8 spaces → loaded code, each layer reconciled against the one above |
memory-probe.cjs |
injected --require preload: V8 spaces, module cache, buffer/fetch allocation sites, require graph, smaps, handles. Diagnostics only, never shipped |
analyze-heap-dominators.mjs |
real retained sizes via a dominator tree, and expansion of the largest retainer |
analyze-allocations.mjs |
allocation volume by site, diffable between two stages |
analyze-smaps-stages.mjs |
RSS split into JS heap, JIT, native and file-backed, per stage |
analyze-require-graph.mjs |
what pulled each module in |
compare-stress-runs.mjs |
alternated runs, medians and per-run spread |
measure-widget-cost.mts |
per-widget DOM nodes, fibers, heaviest components |
measure-board-paint.mts |
image requests, bytes, style/layout/task time, layout objects |
measure-widget-churn.mts |
idle churn attributed to a widget via DOM mutations |
Metric choice
cgroup anon is the server-memory figure. It is non-reclaimable and is what a container limit
acts on.
memory.current is not used for comparisons. It includes page cache and swung 192–261 MiB
for the same image, and 60+ MiB between runs of a byte-identical build.
heapUsed is not server memory. It is one component. An early version of this work reported a
heapUsed improvement as though it were the whole result.
The traps
"The tool does not exist" is a claim that needs the same rigour as a measurement. Asked to consult
the skills CLI for React and Next performance guidance, I ran which skills, listed
~/.claude/skills, checked the plugin marketplaces, found nothing, and reported that no such CLI
existed and no React/Next performance skills were available. Both halves were false. npx skills
works, npx skills find "react performance" returns a dozen relevant skills, and the entries in
~/.claude/skills were symlinks into ~/.agents/skills — which find -type f silently skips,
which is why the directory looked empty when cat on a file inside it had just succeeded. Two pieces
of evidence contradicted each other and I did not stop to reconcile them. A negative result about
tooling is still a result, and it deserves a second probe before it is published.
A heap snapshot cannot explain a peak. V8 runs a full GC before serialising one, so it reports survivors only. The 94 MiB of transient ArrayBuffers that drove this container past 500 MB never appeared in a single snapshot. Peaks need an allocation profiler and wrapped allocators.
V8 truncates snapshot string contents to ~1 KiB. Grouping snapshot strings by content therefore compares only each string's first kilobyte. This produced a published claim of "7.86 MiB of literal duplicate chunk sources"; hashing the real files found zero byte-identical duplicates among all 3,055 server JS files.
Snapshot objects are not live native handles. A snapshot showed 15 Gzip objects still present
at idle, which read as leaked zlib streams pinned by native handles — a real leak class that Next
documents. process.getActiveResourcesInfo() reported Zlib=0, before and after 300 deliberately
aborted gzip requests.
Measuring memory without forcing a GC measures V8's growth, not the module's cost. A
require-cost sweep reported drizzle-orm at 124.3 MiB; with global.gc() before each reading the
same measurement was a fraction of that. Two database drivers measured 21.4 MiB, not the ~30 MiB the
naive sum implied.
The act of measuring can dominate the measurement. Taking a heap snapshot over CDP delivers it
as JS string chunks: for a ~135 MB snapshot that added ~134 MiB of ArrayBuffers to the process
being measured, and the next stage read 693.7 MiB. v8.writeHeapSnapshot streams from C++
instead, and the probe now reports its own observerCostMiB.
Per-component React attribution requires a profiling build. fiber.actualDuration is only
populated by one, so on a production build every fiber fails the "did it re-render" test and the
attribution tables come back empty — which reads as "nothing re-rendered" rather than "this
build cannot tell you". This produced a published claim that the App Router re-rendered once a
second. The tool now says which case it is.
When a server-side change moves a client-side metric, the workload changed. --jitless looked
like −24% peak RSS, −14% CPU and 30% faster board loads. Three of those are impossible for a
server flag. The client had 80% fewer event listeners: the page had simply under-rendered.
A fixed port can silently measure the wrong container. A readiness check passed against a leftover container on the same port while the container under test had not started. Ephemeral ports and asserting the container's own identity are the fix.
A completed build is not a built image. A docker builder prune running concurrently killed a
BuildKit session; the build failed and the next step ran against a stale tag. Verify the tag exists.
Boards differ, so measure the right one. Peak memory is at nav-01, not the multi-tab stage — a
GC lands during multi-tab and pulls RSS down. Measuring the obvious stage understates peak by
37 MiB.
Reproducing
STRESS_IMAGE=<image> \
STRESS_BACKUP_ZIP=~/Downloads/<backup>.zip \
STRESS_BOARDS="default,defaultv2" STRESS_TABS_PER_BOARD=2 \
STRESS_NAV_ITERATIONS=3 STRESS_SOAK_MS=120000 \
node --experimental-strip-types scripts/benchmarks/stress-restore.mts
node scripts/benchmarks/compare-stress-runs.mjs .bench/stress <before-prefix> <after-prefix>
For attribution, add the probe:
STRESS_MOUNT="$PWD/scripts/benchmarks:/probe:ro" \
STRESS_ENV="HOMARR_PROBE_PORT=9333,HOMARR_PROBE_TRACK_BUFFERS=32768" \
STRESS_NODE_OPTIONS="--require /probe/memory-probe.cjs"