Files
homarr/docs/perf/NEXT-STEPS.md
T
Thomas Camlong 8f94ea47e3 docs(perf): add a handoff with the unverified item, the remaining lever, and the clean-PR set
Records where this stopped so it can be picked up cleanly:

  1. c526dd2b7 (media-requests content-visibility) is typechecked but NOT measured - the
     only performance claim on this branch not A/B'd before commit. Verify with
     measure-board-paint.mts, three repeats; revert if image requests do not fall.
  2. The one lever with headroom left is the tRPC router's eager integration imports - a
     16.10 MiB subtree of 460 files reached by every API route, which pays twice because
     both the ~41 MiB of retained source and the ~38 MiB of V8 compiler zones scale with
     how much JavaScript the server compiles. Every allocator and V8-flag avenue is closed.
  3. Deferred with prizes attached: ASCII-escaping bundles (9.45 MiB, needs its own PR),
     lazy mysql2/pg (21.4 MiB, structurally blocked), beszelSystemStats chart SVG.
  4. The proposed set for the clean PR, six changes producing -26% peak anon and -52% at
     idle for +18% CPU - with the gRPC patch flagged as hygiene rather than a measured win,
     and the +30% board-load figure flagged as not established.
2026-08-10 19:12:28 +02:00

5.1 KiB
Raw Blame History

Where this left off, and what to do next

Written as a handoff. The branch is green and everything below is measured except where explicitly marked.

1. Verify the one unmeasured change

c526dd2b7 applies the content-visibility treatment to mediaRequests-requestList. It is typechecked but not measured — the session ended first. Every other performance claim on this branch was A/B'd before commit; this one was not.

# restore two containers on identical data, one per image, then:
STRESS_BASE_URL=http://127.0.0.1:PORT STRESS_BOARD=default \
  node --experimental-strip-types scripts/benchmarks/measure-board-paint.mts

Expect image requests to fall (the measured effect on mediaReleases was 135 → 55). Do not expect JS heap to move: decoded bitmaps live outside JSHeapUsedSize. Three repeats per side — a single run wrongly suggested a −16% heap win on the first widget.

If it does not reduce image requests, revert it. Unmeasured complexity is exactly what this branch has rejected everywhere else.

2. The one lever with real headroom left

The tRPC router imports every integration eagerly. A single 16.10 MiB subtree of 460 files is reached by every API route, and because Next preloads route bundles it is resident before the first request. This pays twice:

  • ~41 MiB of retained bundle source (28% of the live server heap) — V8 keeps a script's source alive while any function in it may still be lazily compiled
  • ~38 MiB of V8 compiler zone memory, which is proportional to how much JavaScript V8 compiles and is not reachable by any V8 flag (--max-opt is a null result; only --jitless removes it, and that breaks rendering)

Nothing else has this profile. Every allocator and V8-level avenue is closed — see REJECTED.md.

Start from analyze-require-graph.mjs, which showed the shared subtree, and [root-of-the-server]__0g06wx7._.js as the chunk that reaches 460 files.

3. Known-good but deliberately deferred

item prize why it was not done
ASCII-escape server bundles 9.45 MiB Proven mechanism (2.00 vs 1.00 bytes/char; one char above U+00FF doubles a whole source string). Needs a build-time rewrite of minified vendor output — deserves its own PR with its own safety argument
Lazy mysql2 / pg 21.4 MiB Three implementations, all broken; see REJECTED.md. Blocked structurally because createDb must stay synchronous
beszelSystemStats at 1,156 nodes unknown Second-heaviest widget, but its cost is chart SVG (<g>×317), so reducing it changes what is drawn
documents reads 18–32 unknown Never investigated; each document carries overhead

4. For the clean PR

The instruction was to close this experimental PR and open one with only the verified fixes, judged on lines-of-code to performance ratio. On the evidence, that set is:

change size measured effect
--max-semi-space-size=4 1 line −19% idle, −21% peak
preloadEntriesOnStart: false 1 line −52% anon at idle; 310 fewer files even at full load
Umami: stop fetching 100k raw events per refresh 1 file −26% peak, −93% transient buffers, largest allocation 46.4 → 2.4 MiB
serverExternalPackages (mysql2/pg + 8 more) 1 block −21 MiB idle
content-visibility on media-release cards 4 lines −59% image requests, −41% layout objects
ssh2 WASM heap (docker-modem patch) 1 patch −15 to −18 MiB external at every stage

Together these produce the headline: −26% peak anon, −52% at idle, no overlapping ranges at any stage, for +18% CPU.

Two things to leave out or flag:

  • The gRPC patch removes 112 files and 6 MiB from an isolated require, but is not separately visible in a full run. It is bundle hygiene, not a measured memory win.
  • Median board load reads +30% but is not established — individual loads were [506, 383, 1233] and [848, 431, 574] before against [780, 808, 515] and [621, 579, 1004] after, heavily overlapping with no cold-then-warm pattern. If the latency trade matters, measure it properly with many more samples; the suspicion is that it is real and concentrated in preloadEntriesOnStart.

5. Read this before measuring anything

METHODOLOGY.md. Every wrong number in this investigation came from an instrument, not the application, and several were published before being caught. The short version:

  • a heap snapshot cannot explain a peak — V8 GCs first and reports survivors only
  • V8 truncates snapshot string contents to ~1 KiB
  • snapshot objects are not live native handles
  • measuring without a forced GC measures V8's growth, not a module's cost
  • taking a CDP heap snapshot added 134 MiB to the process being measured
  • React per-component attribution silently returns nothing on a production build
  • a fixed port can measure a leftover container
  • when a server-side change moves a client-side metric, the workload changed — this is how --jitless was caught faking a 24% win by under-rendering the page