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.
Closes the largest unexplained item in the peak breakdown, then closes it off as a lever.
Attribution: peak_malloced_memory goes 4.5 -> 42.1 MiB the moment a board's modules
compile, while current malloced_memory stays at 1.0 - memory V8 takes to compile, frees
back to malloc, and musl never returns to the kernel. Confirmed by re-reading the
--jitless runs, where the spike never occurs at all (4.5 MiB at every stage).
--jitless is unusable because it under-renders the page, but V8 can cap the optimisation
tier instead of removing codegen. --max-opt is rejected by NODE_OPTIONS and accepted on
the command line, so it was wired behind HOMARR_MAX_OPT and A/B'd on one image:
peak anon 276.0 -> 282.8 MiB (+2%)
boot idle 64.0 -> 72.3 MiB (+13%)
board loaded 186.1 -> 179.2 MiB (-4%)
4 tabs 270.8 -> 238.5 MiB (-12%)
after soak 199.0 -> 214.3 MiB (+8%)
CPU 28.1 -> 27.8 s (unchanged)
Null result. Render integrity was verified explicitly this time - listeners 7,784 in all
four runs, DOM nodes within 40 - so unlike jitless the page rendered fully and the
comparison is valid.
The mechanism explains it: measured under --max-opt=2, peak_malloced_memory at board load
is 42.1 MiB, identical to Turbofan. Maglev's zones are just as large. Capping the tier does
not reduce zone memory; only not compiling does, and that breaks rendering.
So this 38 MiB is not reachable through V8 configuration. It scales with how much
JavaScript V8 must compile, which returns it to the single remaining server-side lever:
ship less server code. Reverted.
Two things this settles.
First, the end-to-end number. Every Phase 2 result so far was an incremental A/B against
the build immediately before it, which cannot be summed. Rebuilt the merge-base
(06724cfeb) from a clean worktree and ran 2+2 alternated against HEAD, reporting cgroup
anonymous memory - the figure that counts against a container limit and cannot be
reclaimed:
stage before after
peak anon 376.6 -> 279.3 MiB (-26%)
boot idle 130.5 -> 62.4 MiB (-52%)
after sign-in 247.4 -> 142.9 MiB (-42%)
dashboard 293.4 -> 170.4 MiB (-42%)
4 tabs 366.5 -> 258.2 MiB (-30%)
after soak 323.7 -> 200.8 MiB (-38%)
peak node RSS 430.7 -> 336.2 MiB (-22%)
No before/after range overlaps at any stage. Costs: CPU 23.5 -> 27.7s (+18%, solid,
non-overlapping). 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, so six samples per side cannot
separate a real regression from the variance.
compare-stress-runs.mjs now leads with anonMiB, which the harness recorded all along but
the report was ignoring in favour of memory.current - a figure that includes page cache
and swings 60+ MiB between runs of a byte-identical image.
Second, jemalloc. The largest attributed-but-unfixed item is ~38 MiB of V8 zone memory
that musl frees to its own pools and never returns to the kernel, so an allocator that
decays spans back to the OS looked like the answer. Tested on the byte-identical image
with only HOMARR_MALLOC differing, asserting the "Using jemalloc" log line:
boot idle 62.4 -> 80.3 MiB (+29%)
after soak 215.5 -> 279.6 MiB (+30%)
4 tabs 259.9 -> 319.6 MiB (+23%)
CPU 28.0 -> 24.5 s (-13%)
The hypothesis was backwards. jemalloc keeps more freed memory on purpose - thread caches
plus time-based dirty-page decay - and spends it on speed. 13% cheaper CPU, 18-30% more
resident memory. Reverted, and it closes off "swap the allocator" as a RAM direction:
musl is already the frugal choice here.
Second correction in the same area. Having found that three Beszel widgets account for
82% of idle DOM churn, the obvious fix was to batch their subscription updates instead
of calling setState per record. Implemented, built, measured:
DOM mutations / 45s 733 -> 748
React commits / 45s 55 -> 55
Identical. Beszel emits roughly one record per second and the flush interval was also
one second, so each flush batched exactly one record. Coalescing at the arrival rate
does nothing - arithmetic that should have come before the implementation.
That also exposes a unit error in the previous commit's numbers: DOM mutations are not
re-renders. The real rates are 1.22 React commits/sec for the whole board and 13.3 DOM
mutations per commit, because a chart redraw touches many nodes. The stats widget's
"10.4 mutations/sec" is ~0.8 renders/sec - three live-monitoring widgets each refreshing
about once a second, which is what they exist to do.
So 73 commits/min is not pathological, it is the feature working, and there is no defect
to fix here. Reverted rather than shipped: keeping speculative complexity that measured
zero - an extra timer plus unmount and system-change teardown - is the trade this work
rejects everywhere else.
What survives is narrower: idle churn is widget-driven rather than app-shell driven, and
legitimate. The 650 MB browser heap therefore comes from interaction and multi-tab use,
not idle churn, and remains unexplained.
Corrects the largest wrong claim in this work. Earlier rounds reported that ~80% of
idle React commits entered at Next's internal HeadManagerContext and that "the App
Router root re-renders about once a second", and named it the highest-value next step.
Both are false. Measuring the same idle churn against boards of different sizes:
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. If the router re-rendered on a timer,
every board would churn regardless of its contents.
The original attribution was a tooling artefact. measure-rerenders.mts decides a fiber
re-rendered from fiber.actualDuration, which only a React *profiling* build populates.
On a production build every fiber fails that test and both attribution tables come back
empty - and an empty table reads as "nothing re-rendered" rather than "this build cannot
tell you". The harness now says which case it is, and waits for the board container to be
attached rather than visible so a zero-item board can be measured at all - that control
is what disproved the claim.
Adds measure-widget-churn.mts, which attributes churn on a production build via DOM
mutations keyed to the nearest [data-type="item"] ancestor. On the 28-item board, idle
for 45s:
470 beszelSystemStats 64%
77 beszelSystemTable 11%
52 beszelSystemGrid 7%
22 dockerContainers 3%
93 (outside any widget) 13%
Three Beszel widgets are 82% of all idle churn, one of them 64% on its own at 10.4
mutations per second.
Also logs what Next.js itself costs, since it was assumed to be the problem: its loaded
source is a flat 3.47 MiB (364 framework files + 32 vendored) and does not grow at all
between boot and a fully loaded multi-tab session, while Homarr's own chunks grow from
3.73 to 15.44 MiB. Next is 16.1 MiB on disk; the largest things it vendors are its
next-server runtime (1.60), @edge-runtime (0.85), edge-runtime (0.51) and compression
(0.30). The framework is not where this application's memory goes.
Closes the last unexplained part of the peak footprint. The 118.8 MiB 'other
anonymous' smaps bucket is now mostly accounted for: V8 large-object space 34.5,
external buffers 24.6, and ~38 MiB of V8 parser/compiler zone memory that was freed
back to malloc but never returned to the kernel by musl - peak_malloced_memory goes
4.6 -> 42.2 MiB at board load while current malloced_memory stays at 1.0.
Two theories tested and rejected rather than asserted:
- 'Fewer loaded files means less compiler zone memory, so shipping less server JS
pays twice.' Wrong. peak_malloced_memory is identical (42.1-42.5) across the
preloadEntriesOnStart A/B despite 310 fewer files. The spike is a fixed cost of
the board route, not a per-file one. Claim withdrawn.
- Orphaned zlib streams. Next documents this leak class and the fix is absent from
this build, and a peak snapshot showed 15 Gzip objects still present at idle -
which read as accumulation. getActiveResourcesInfo() reports Zlib=0 both at
baseline and after 300 deliberately aborted gzip requests, RSS flat. Snapshot
objects are not live native handles, and nginx buffers responses so Next never
sees the mid-response disconnect that orphans a stream.
Adds a /handles endpoint to the probe, which is what settled the second one.
Records the Phase 2 peak work:
- Corrects the earlier claim that Next preloading all 74 route graphs is
'framework behaviour, not something this codebase sets'. Next documents
experimental.preloadEntriesOnStart for exactly this, found by reading its
memory-usage guide rather than by inference. Includes the deterministic
per-stage table: 310 files and 6.3 MiB of source stay unloaded even after a
full multi-tab session, so the cost is not deferred, it is never paid.
- Rejects --jitless with evidence. Code space really does go 11.6 -> 0.0 MiB and
peak RSS looked 24% better, but the flag was applied to the server while the
browser's listener count fell 80% and board load got faster, which is
impossible. The page under-rendered; the win was an app doing less work. The
tell generalises: a server-side change moving a client-side metric means the
workload changed and the comparison is void.
- Notes that --optimize-for-size, --no-opt and --lite-mode are rejected outright
by Node in NODE_OPTIONS, and that the rest of Next's memory guide covers
build-time memory under Webpack, which does not apply to this Turbopack build.
mysql2 (77 files) and pg (13 files) load on every SQLite install because
db/drivers/index.ts imports all three wrappers statically. Re-measured with a
forced GC between readings, the prize is 21.4 MiB RSS - not the ~30 MiB the naive
sum implied, and drizzle-orm's 124.3 MiB in the require-cost sweep was almost
entirely that same no-GC artefact.
Three attempts, each caught by booting the real image rather than by typecheck:
createRequire(import.meta.url) is undefined in the CommonJS migrate.cjs bundle and
broke startup for every dialect; a dynamic require resolves to a .cjs build Next
never traces; and leaving drizzle's dialect static defeats the purpose because
drizzle-orm/mysql2 itself imports mysql2.
The two remaining routes are worse than the problem - a second copy of drizzle's
core alongside the ESM one, or hardcoding drizzle's internal file layout so an
upgrade breaks startup for MySQL and Postgres. Backed out; the structural blocker
is that createDb must stay synchronous.
Profiling at the memory high-water mark - not at idle, which is what every earlier
round measured - found the largest single retainer in every snapshot was one V8
Context holding a 16.00 MiB ArrayBuffer. Expanding it through the dominator tree
named it: ssh2's crypto module, whose POLY1305_WASM_MODULE instantiates a
WebAssembly memory for ChaCha20-Poly1305 as soon as it is required.
Homarr has no SSH feature and does not depend on ssh2. The chain is
dockerode -> docker-modem -> ssh2, and docker-modem/lib/modem.js requires './ssh'
at module scope while using it in exactly one place, already guarded by
`protocol === 'ssh'`. A pnpm patch moves the require inside that branch. SSH-based
Docker hosts still work - require() is cached, so they pay it once - and every
socket-based install, which is everyone else, never loads it.
Measured over four alternated runs on a restored real backup, external memory:
before after
board loaded 25.4, 25.1 8.3, 11.2
nav-01 28.5, 25.4 10.1, 8.6
multi-tab 31.1, 35.8 13.0, 10.0
post-soak 21.4, 21.5 5.5, 7.2
Consistently -15 to -18 MiB, ranges non-overlapping, and 30 fewer files loaded
(1470 -> 1440).
Peak RSS does NOT improve, and that is worth stating plainly: V8 sizes its heap
against available headroom, so freeing native memory lets it grow further before
collecting. What changes is the composition - a fixed 16 MiB of unusable WASM
buffer becomes headroom the app can actually use, which matters under a container
memory limit but does not move the peak number.
Also adds the instruments this needed. A heap snapshot can never explain a peak,
because V8 runs a full GC before serialising one and reports only survivors:
- memory-probe.cjs gains in-process allocation sampling, on-demand snapshots via
v8.writeHeapSnapshot, an smaps endpoint, and require-graph recording.
- analyze-allocations.mjs diffs two stages to isolate what one stage allocated.
- analyze-smaps-stages.mjs splits each stage's RSS into JS heap, JIT, native and
file-backed pages.
- analyze-heap-dominators.mjs can now expand the largest retainer, which is what
identified ssh2. A plain graph walk escapes through __proto__ and is useless.
One trap recorded in the findings: taking the peak snapshot over CDP delivers it as
JS string chunks and added ~134 MiB of ArrayBuffers to the process being measured,
producing a fake 693 MiB reading. v8.writeHeapSnapshot streams from C++ instead, and
the probe now reports its own observerCostMiB so the cost of looking is visible.
The live site has >100k events for one event name, so the paged path logs that its
counts are a lower bound. The previous code had the identical 100k ceiling - the
46.3 MiB body was that ceiling being hit - but truncated in silence. The ceiling is
unchanged; it is now visible.
A flat module cache says 24 MiB of source is resident before the first request, but not
what pulled any of it in. Recording parent -> child as the CommonJS loader resolves it
answers that, and the answer redirects the remaining 'ship less server JS' work:
- Next loads all 74 app route bundles at startup, not lazily.
- One 16.10 MiB / 460-file subtree is reached by the tRPC/API routes AND by
instrumentation, so instrumentation's exclusive cost is 0 bytes - deferring it would
free nothing, because Next preloads /api/[...trpc] anyway.
- Only 1.10 MiB is common to every route, so this is the API surface pulling in every
integration, not a universal barrel.
Profiling the container end to end found one allocation site responsible for
most of the peak: a single 46.3 MiB JSON body, assembled from 3,946 socket
chunks, inside undici's fullyReadBody. That function holds the chunk list and
the joined copy at the same instant, so a body of size N costs about 2N.
The source was two Umami calls asking for pageSize=100000, with
getMultiEventTimeSeriesAsync running them for every tracked event name
concurrently via Promise.all. getEventNamesAsync was downloading every raw
event of the last 30 days purely to collect a handful of distinct name strings.
- getEventNamesAsync now reads the aggregated /metrics?type=event endpoint.
- getWebsiteEventTimeSeriesAsync pages 5,000 records at a time, folding each
page into the bucket map before requesting the next.
- getMultiEventTimeSeriesAsync runs series sequentially.
Instances that return aggregated {x,y} buckets still answer in one request, an
instance that accepts pageSize but ignores page is detected rather than
double-counted, and hitting the page ceiling logs instead of silently
undercounting.
Alternated runs, 2 per side, on a restored real backup:
peak container 498.9 -> 398.5 MiB (-20%)
peak node RSS 452.4 -> 353.3 MiB (-22%)
peak during 4 tabs 498.5 -> 356.3 MiB (-29%)
peak arrayBuffers 95.8 -> 6.6 MiB (-93%)
largest allocation 46.4 -> 2.4 MiB (-95%)
Costs +4% CPU from the extra round trips; board load latency unchanged.
Post-soak steady state and boot idle are unchanged within run-to-run spread.
Adds the attribution tooling this needed: attribute-memory.mts reconciles
cgroup -> processes -> address-space mappings -> V8 spaces -> loaded code,
memory-probe.cjs is an injectable diagnostics preload (never shipped) that
tracks buffer and fetch allocation sites, analyze-heap-dominators.mjs computes
real retained sizes, and compare-stress-runs.mjs reports alternated runs with
their spread.
Also corrects an earlier claim in PHASE2-FINDINGS.md: the reported "7.86 MiB of
duplicate chunk sources" was an artefact of V8 truncating string contents to
~1 KiB in heap snapshots. There are zero byte-identical duplicates among all
3,055 server JS files. The real doubling is character width - one codepoint
above U+00FF forces a whole source string to two bytes per character, which
costs 9.45 MiB across 80 loaded files.
Adds 6 tests; this integration had none.
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.
All previous numbers were spread across rounds with different instruments and
different workloads. This is one instrument, one workload, alternated, 2 runs per
side, merge-base vs HEAD:
server idle 141.9 -> 89.7 MiB (-37%)
server loaded 257.3 -> 173.0 MiB (-33%)
server peak 705.2 -> 568.4 MiB (-19%)
after idle soak 332.0 -> 232.5 MiB (-30%)
JS event listeners 10,126 -> 7,774 (-23%)
React fibers 5,224 -> 3,588 (-31%)
DOM nodes 9,175 -> 8,284 (-10%)
browser JS heap 55.2 -> 51.1 MiB (-7%)
image 410 -> 398 MB (-3%)
container CPU 22.8 -> 22.8 s unchanged
Recorded prominently rather than buried: under *sustained* 4-tab load the two builds
converge, 368 vs 364 MiB. The wins are idle, steady state and peak. That is why
hammering the app with five tabs did not feel 30% lighter — at saturation the
footprint is live widget data and render churn, which this PR does not change.
Also documents the gaps, since "tested" without them would be misleading: no
multi-hour soak (the #5301 reports are about days), single user, SQLite only, the
DevTools Profiler tab confirmed at bundle level rather than in the extension, and
the App Router re-rendering ~1x/second still unfixed.
verify-context-menu.mts now also covers touch long-press, and no longer asserts on a
fiber total — that counter swung 7,454 to 16,149 between runs of the same build, so
it was noise. probe-fiber-tree.mts is the stable counter and is what the -31% above
comes from.
measure-rerenders.mts counts React commits on an untouched board and attributes each
to the shallowest component that re-rendered, i.e. where the update entered the tree.
What it found: an idle board commits 73-96 times per minute, re-rendering
@mantine/core/Box over 30,000 times in 60s. That is the churn behind the 650 MB
browser peak — transient allocation rather than retention. About 80% of those commits
enter at the same anonymous depth-2 provider, props [value, children], value {appDir}:
Next.js's internal HeadManagerContext, whose value is a fresh object literal on every
App Router render, so each render re-renders the whole app beneath it. The rest enter
at __next_metadata_boundary__ / <title>, pointing the same way.
The trigger is inside Next's router/metadata handling — application code has no
polling router.refresh() — so nothing here fixes it. It is the next thing to chase and
it dominates everything downstream of it.
useTimeAgo called setState on a 1s interval even when the label was unchanged ("3
minutes ago" holds for a full minute), re-rendering its subtree ~60x/minute per
consumer for an identical string. It now compares before setting. Recorded as a
correctness fix and NOT a measured win: idle commits stayed inside their 73-96 range
afterwards, because the app-root churn dominates.
pnpm test 1794/1795 (pre-existing pi-hole Testcontainers flake); typecheck clean
across 37 packages; no new lint errors.
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.
Mantine's Menu mounts Popover + PopoverDropdown + several context providers even
while closed, so a menu per widget was ~13 fibers each for something almost never
opened. WidgetContextMenuProvider now mounts a single menu for the board, anchored
to the cursor; items keep only a contextmenu/long-press listener.
The dropdown takes the section's gridstack ref as a prop rather than reading
SectionContext — the shared menu lives outside any section, and reading it from
context there crashed the board with "useSectionContext must be used within a
SectionContext".
Verified against both builds with scripts/benchmarks/verify-context-menu.mts
(opens on right-click, shows actions, closes on Escape, re-targets to a second
widget):
idle fibers 18,670 -> 16,278 (-12.8%)
fibers after use +4,191 -> +4
JS event listeners 10,128 -> 7,774 (-23%)
DOM nodes 9,189 -> 8,287 (-10%)
browser JS heap 54.1 -> 52.7 MiB
median board load 520 -> 479 ms
container CPU 20.4 -> 20.0 s
Two runs per side, four tabs across two boards. Server memory is unchanged, as
expected for a client-only change. The listener reduction is the point: the 650 MB
session had ~88k listeners and this removes a per-widget source of them.
The gate is validated by passing against unmodified code first. An earlier version
used Playwright's click({ button: "right" }), which emits no contextmenu event
here and so failed shipped code identically — it wrongly condemned a previous
attempt at this optimisation. A gate never run against the un-optimised build
proves only that something changed, not that it still works.
pnpm test 1794/1795 (pre-existing pi-hole Testcontainers flake, 9/9 in isolation);
typecheck clean across 37 packages; 0 lint errors.
probe-fiber-tree.mts walks the live React tree and reports which components are
mounted, what DOM they render and their props. Heap snapshots only give minified
names like `y`, and source maps do not help because the snapshot records runtime
names — props shape plus rendered DOM identifies a component reliably. It injects
a DevTools hook stub before load so React registers every root; climbing from one
DOM node's fiber missed most of the tree (2,093 fibers vs 3,918).
What it found, on a 28-widget board:
- The most-mounted component is @mantine/core/Box (513). It is the primitive every
Mantine component renders through, so it is a symptom of how much is mounted
rather than a bug. Proportions match the 650 MB session snapshot (13% vs 17%),
so the minified `y` there is almost certainly Box.
- 71 Menu -> Popover -> PopoverDropdown triplets are mounted for context menus that
are almost never opened. Isolated with the existing enableRightClickOnWidgets
flag: 3,918 fibers with them, 3,554 without — 364 fibers, 9%, scaling with item
count. The dropdown contents are already lazy; the wrappers are not.
A fix that deferred the Menu until first right-click was reverted: it broke
right-click and inflated the tree to 20,107 fibers, because the replayed
contextmenu event re-entered the arming handler. Doing it properly needs one
board-level menu instead of one per item. The Playwright check that caught this is
in the findings — a fiber count alone would have read as a win.
Also records that the demo account had completed_board_tour = 0, so earlier rounds
measured the first-time-user path; completing it moves the tree by 38 fibers, so
the published numbers stand.
Headline moves from single-sample -36% to repetition-backed -34% idle /
-35% loaded (2 before, 3 after, groups non-overlapping).
Round 3 (API-docs SPA lazy-loaded, MCP SDK external) is recorded as NOT a
memory win: every figure lands inside the previous build's spread, so it is
credited only with the deterministic -3 MB image.
Also records the measured cost of the levers not pursued: in-image Redis is
8.1 MiB RSS (the catalog guessed 10-40 MB) and nginx 9.2 MiB RSS / 3.1 PSS,
so neither justifies its deployment risk. next-server is 225.8 of ~236 MiB
at board-loaded, so anything further has to come out of that 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.
Adds the GC-tuning and wider-unbundling numbers, states the CPU and image-size
costs alongside the memory wins, and documents the three assertions that now
prove the measurement runs behind a real session rather than a public page.
Removing the three zero-effect changes cost nothing: idle 120.0 MiB,
board loaded 229.5, after stress 357.0 — all within noise of the
untrimmed branch, with the lowest peak of any config measured.
Measured on a populated instance (real backup restored through the
onboarding UI, 28-widget board open) rather than an empty container, and
compared on anonymous memory because memory.current swung 192-261 MiB
for the same image once page cache was counted.
Branch vs merge-base: -24% idle, -11% board loaded, -8% under load.
Cumulative ablation from the baseline image shows where that comes from:
adding mysql2/pg to serverExternalPackages — one line — accounts for the
entire idle win (-21 MiB), because the driver barrel statically imports
all three drivers and bundling inlined mysql2's whole graph into the
boot-loaded server chunks (108 MB of chunks vs 95 MB, and mysql2 absent
from node_modules because it had been inlined). The integrations barrel
diet carries the under-load win but moves idle by 0. Redis 6->2 clients
and the V8 heap cap both move idle by 0 — the cap is a drift ceiling,
not a reduction, since removing it entirely (cap4096) matched the branch
at every stage.
The cache-invalidation layer and next-cache-handler package measure at
exactly zero: no "use cache", cacheTag, cacheLife or unstable_cache
anywhere in app source, and cacheHandlers/cacheComponents are already
gone from next.config.ts, so 66 revalidateTag call sites across ~30
routers are no-ops. Keep/drop decision left open.