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.
Homarr performance investigation
Measured work on Homarr's memory and rendering cost, split by subject. Every figure here comes from
a run of scripts/benchmarks/stress-restore.mts against a restored real backup — 4 boards, 51
widgets, 11 integrations, driven through an actual login — not from a synthetic or empty container.
| document | subject |
|---|---|
| SERVER-MEMORY.md | What the container's RAM is made of, and the changes that reduced it |
| CLIENT-PERFORMANCE.md | Dashboard rendering cost in Chrome: DOM, fibers, images, paint |
| REJECTED.md | Ideas that were implemented and measured, then abandoned — with the numbers |
| METHODOLOGY.md | The tools, and the measurement traps that produced wrong answers |
| NEXT-STEPS.md | Handoff: what is unverified, what to do next, and the set for a clean PR |
Headline result
Server RAM, merge-base 06724cfeb vs this branch, two alternated runs per side, reported as cgroup
anonymous memory because that is what counts against a container limit and cannot be reclaimed:
| stage | before | after | change |
|---|---|---|---|
| peak | 376.6 MiB | 279.3 MiB | −26% |
| boot idle | 130.5 MiB | 62.4 MiB | −52% |
| after sign-in | 247.4 MiB | 142.9 MiB | −42% |
| dashboard loaded | 293.4 MiB | 170.4 MiB | −42% |
| 4 tabs, 2 boards | 366.5 MiB | 258.2 MiB | −30% |
| after 2 min idle soak | 323.7 MiB | 200.8 MiB | −38% |
No before/after range overlaps at any stage. The cost is +18% CPU (solid, non-overlapping).
The changes that did it, by ratio
| change | size | 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 |
serverExternalPackages (mysql2/pg + 8 more) |
1 block | −21 MiB idle |
| ssh2's 16 MiB WASM heap (docker-modem patch) | 1 patch | −15 to −18 MiB external |
content-visibility on media-release cards |
4 lines | −59% image requests, −41% layout objects |
What is left, and why it is hard
One lever has real headroom and it pays twice. Roughly 41 MiB of retained bundle source and ~38 MiB of V8 compiler zone memory both scale with how much JavaScript the server loads and compiles, and the largest single contributor is a 16.10 MiB subtree reached by every API route — the tRPC router importing every integration eagerly. Reducing that reduces both.
Everything else has been measured and closed: see REJECTED.md.