Next loads the module graph for all 74 app routes when the server starts. Measured
here: 1,114 files and 24 MiB of source resident before the first request, including
one shared 16 MiB subtree behind the API routes that reaches every integration
client. A dashboard serves a handful of routes per session.
`experimental.preloadEntriesOnStart: false` is Next's documented switch for this.
An earlier round of this work recorded route preloading as "framework behaviour, not
something this codebase sets" - that was wrong, and the findings doc is corrected.
Deterministic per-stage measurements, identical across both runs on each side:
stage source loaded files heapUsed
boot idle 24.0 -> 10.6 MiB 1114 -> 706 70.7 -> 44.8 (-37%)
post-login 27.9 -> 19.3 MiB 1215 -> 884 123.8 -> 109.8
board loaded 29.0 -> 21.7 MiB 1323 -> 1006 (run-to-run noise)
multi-tab 29.2 -> 22.9 MiB 1325 -> 1015 139.7 -> 127.8
post-soak 29.2 -> 22.9 MiB 1325 -> 1015 142.5 -> 127.4
The important part is the last two rows: 310 files and 6.3 MiB of source stay
unloaded even after a full multi-tab session, so this is not merely deferred cost -
routes the session never visits are never paid for at all. Live heap is lower at
every stage, by 26 MiB at idle and 12-15 MiB under load and at rest.
Peak node RSS does not move (350.3 -> 346.1, spreads overlap). V8 sizes its heap
against available headroom, so a smaller live set does not shrink peak RSS; what
improves is idle footprint and how much room is left before the heap cap. Median
board load is +3% with overlapping spreads, so the first-hit cost this trades for
did not show at this workload.
Full E2E suite passes 8/8 against the built image.
Tested the hypothesis that cached base64 images were bloating TanStack Query and
Redis. They are not, measured on a loaded 4-tab session:
base64 data-URLs, server heap 0.01 MiB (2 strings)
base64 blobs, browser heap 0.01 MiB (7 strings)
Redis total 2.17 MB
image bytes in Redis none - image-proxy stores URL + headers only
What the server heap is actually made of (141 MiB used): 41.4 MiB is bundled
JavaScript source retained as strings, ~29% of the heap. The memory is the code, not
cached data — which is why every cache-tuning idea in the fix catalog measured at
zero, and why serverExternalPackages was the only lever that moved idle memory.
7.86 MiB of that is literal duplicate copies: the same module inlined into several
Turbopack chunks and retained once per chunk. @ioredis/commands' command table was in
10 chunks and resident 6 times (2.07 MiB wasted), so it is now external — ioredis is
already external and owns it as a direct dependency, so it stays traced.
Recorded as bundle hygiene, NOT a memory win: idle 89.7 -> 89.4 MiB is inside noise,
and board-loaded/peak deltas were smaller than the run-to-run spread (one "before" run
read 220.6 MiB against 169-173 for identical code).
got and @octokit/request-error duplicate just as badly but were deliberately left
bundled: they are only transitive dependencies, and a check inside the image showed
got is not traced into node_modules, so externalising it would have turned a bundled
module into a require that resolves to nothing — a possible MODULE_NOT_FOUND for zero
measured gain.
analyze-heap-strings.mjs buckets a snapshot's strings by what they are, which is what
made "37 MiB of strings" actionable instead of just alarming.
Verified: 28/28 widgets render, auth control passes, zero module-not-found in the
container log, typecheck clean. pnpm test 1794/1795 with two different Testcontainers
flakes across runs (pi-hole, sabnzbd); both pass 18/18 in isolation.
DevTools still reported "Profiling not supported" because the build was never a
profiling one, despite three settings appearing to enable it. Next applies the React
swap through a webpack alias — getReactProfilingInProduction, mapping
'react-dom/client$' to 'react-dom/profiling' — that Turbopack never sees. A Turbopack
resolveAlias does not catch it either: the vendored entry requires
./cjs/react-dom-client.production.js relatively, not through a bare specifier.
The Dockerfile now performs that same swap on the vendored entry when
HOMARR_PROFILING=true, guarded so it fails the build loudly if React's file layout
changes rather than silently producing a normal build again.
Added `pnpm docker:profiling`, which builds and then verifies, because
`docker build . -t homarr:performance` alone yields a non-profiling image and that is
almost certainly what was being run.
verify-react-profiling.mts checks the bundle rather than the runtime: 2 client chunks
carry React's profiling renderer with the flag, 0 without. It replaces a Playwright
probe that could not tell the two apart — React sends a reduced inject payload to a
synthetic devtools hook, byte-identical for both builds, so that probe reported a
false negative for profiling and production alike. The bundle marker discriminates;
confirm the Profiler tab itself with the real extension.
The container-only harness was measuring the wrong thing. Real usage — several
tabs, several boards, clicking into widgets — reaches ~880 MB server and a
650 MB *browser* heap, and the browser was never measured at all.
Profiling build: `docker build --build-arg HOMARR_PROFILING=true . -t
homarr:performance`. React's production bundle strips the hooks DevTools needs,
hence "Profiling not supported". Note reactProductionProfiling in next.config is
read only by the webpack path; under Turbopack it comes from `next build
--profile`, and turbo strips any env var not declared in the task's `env`, so all
three had to line up before the flag actually took effect — verified by markers
present only in React's profiling bundle, and static chunks going 20 -> 84 MB
with source maps.
inspect-container.mts attaches to an already-running container (SIGUSR1 opens
Node's inspector — no rebuild, no restart), reports cgroup/per-process/V8 memory
and can pull a heap snapshot out over CDP. The inspected context has no require
and no dynamic import, so it reads globals and uses CDP domains instead.
analyze-heapsnapshot / analyze-react-fibers / analyze-heap-retainers summarise a
snapshot too large for DevTools to open.
Harness now opens STRESS_BOARDS across STRESS_TABS_PER_BOARD tabs, clicks into
widgets, and records browser heap, DOM nodes and listener count via CDP.
Performance.getMetrics silently returns nothing unless the domain is enabled,
which is why the first run reported a 0 MiB client heap.
@scalar/api-reference-react is a documentation SPA reachable only from one
admin tab, and it renders nothing usable on the server, yet it was compiled
into the server graph for an SSR pass whose output is discarded. It is a
client component already, so next/dynamic with ssr: false is allowed in
place and needs no restructuring. @modelcontextprotocol/sdk is only reachable
through /api/mcp/[transport], so it moves to serverExternalPackages.
Honest accounting: this is an image-size and architecture change, not a
measured memory win. Idle landed at 89.4 MiB against 90.3/91.1/100.5 for the
previous build — the better end of that spread, but inside it, so on one
sample it cannot be claimed as a reduction. What is deterministic is image
size, 401 -> 398 MB. Kept because a docs SPA should not be server-rendered
regardless of what the memory number does.
pnpm typecheck and lint clean; pnpm test 1794/1795 with the known
Testcontainers pi-hole flake, which passes 9/9 in isolation.
Two further wins, both measured on a restored 28-widget board behind a real
demo session (scripts/benchmarks/stress-restore.mts).
--max-semi-space-size=4 in run.sh. --max-old-space-size only bounds the old
generation; V8 also sizes the young generation generously (16 MB per
semi-space) and that slack is pure resident memory. 4 MB cut idle anonymous
memory 120.0 -> 97.2 MiB and peak 710.6 -> 560.2 MiB. It costs CPU, as any
GC-space reduction does — 15.8 -> 17.1 CPU-seconds over the run, ~8% — with
no board-load latency regression (median 594 -> 570 ms). Overridable via
HOMARR_MAX_SEMI_SPACE_SIZE.
serverExternalPackages for the remaining server-only dependencies. Bundling
a package inlines its transitive graph into a chunk that is compiled at boot
even when the code path never runs; leaving it external makes it a lazy
require. Adding winston, drizzle-orm, ical.js, jszip, ldapts, node-unifi,
@kubernetes/client-node and linkedom took idle to 90.3 MiB and board-loaded
209.6 -> 164.6 MiB, with slightly lower CPU (16.3s) and latency (549 ms).
The trade-off is image size: 388 -> 401 MB, because those packages now ship
in node_modules instead of being tree-shaken into chunks.
Cumulative vs the merge-base: idle 141.7 -> 90.3 MiB (-36%), board loaded
257.8 -> 164.6 MiB (-36%), under load 392.7 -> 302.5 MiB (-23%).
The harness now proves the session rather than inferring it: it asserts an
anonymous request to the private board is denied, requires the Auth.js
session cookie after sign-in, and reports whether it had to modify the
restored data at all (now "none"). It also records cgroup CPU seconds and
board-load latency, so a memory win bought with CPU cannot hide.
pnpm test 144/144 files, 1795 tests; typecheck and lint clean.
Revert layout.tsx, _layout-creator.tsx, manage/layout.tsx,
_theme.tsx, _creator.tsx, mantine.tsx, and login/page.tsx
to origin/dev — the Suspense provider splits caused
"EditMode is required", stream close, and React #419 errors.
Also remove cacheComponents/cacheHandlers from next.config.ts
(incompatible with auth/i18n/dynamic rendering) and fix
Boolean() truthy bug in Redis connection.
CodeRabbit review addressed:
- Removed the env-var gating (simpler: just remove source maps entirely)
- CI health check now fails fast if container never becomes healthy
Changes vs origin/main:
- next.config.ts: remove productionBrowserSourceMaps and serverSourceMaps
(they default to false, restoring pre-v1.68.0 behavior)
- Dockerfile: remove .map copy step and NODE_OPTIONS=--enable-source-maps
- CI: add memory-regression job (<700MB guardrail)
Closes#6123
Production source maps (--enable-source-maps) caused a ~50% memory increase
(600MB to 1000MB) in v1.68.0. Node.js loads all .map files into memory when
this flag is enabled, and Next.js server chunks generate hundreds of map files.
Changes:
- Guard productionBrowserSourceMaps and serverSourceMaps behind
HOMARR_ENABLE_SOURCE_MAPS env var (default off)
- Guard .map copy step and NODE_OPTIONS in Dockerfile behind
ENABLE_SOURCE_MAPS build arg (default false)
- run.sh checks HOMARR_ENABLE_SOURCE_MAPS before setting --enable-source-maps
- Add Docker-based memory regression CI job (<700MB threshold)
Closes#6123