Both patches came out of this work and both are removed at the maintainer's request. The finding was real - ssh2's WASM heap costs +14.0 MiB RSS on require, and @grpc/grpc-js another 14.6 MiB across 61 files, neither of which a socket-based Docker install ever uses - but patching a dependency means every future dockerode or docker-modem bump fails until someone regenerates the patch, and that recurring cost is not worth roughly 25 MiB. The finding stays on record in SERVER-MEMORY.md because the fix belongs upstream: both packages require a module at file scope while using it in exactly one already-guarded branch, so a PR to each would fix it for every consumer with no patch to maintain here. The two remaining patches, @types/node-unifi and trpc-to-openapi, predate this work and are untouched. allowUnusedPatches was already set on dev and is left alone. Lockfile regenerated: patchedDependencies now lists only those two. Also sharpens the Cache Components entry. The SqliteError was simply an unmigrated build database, which is fixable - but feeding the build a database would be the wrong fix, because serverSetting is per-installation data on the user's volume and a shell prerendered against the build machine's database would bake one install's settings into every other install's first paint. The error was Next.js correctly reporting a request-time read sitting in the shell path.
224 lines
13 KiB
Markdown
224 lines
13 KiB
Markdown
# Ideas measured and abandoned
|
||
|
||
Each of these was implemented or configured, built, and measured. They are recorded with their
|
||
numbers so nobody repeats them, and because several looked like wins until the measurement came in.
|
||
|
||
## Cache Components — the app cannot prerender, and the prize is ~20 ms
|
||
|
||
Tried properly: `cacheComponents: true` at top level on Next.js 16.3.0-preview.9. The flag is
|
||
recognised (`- Cache Components enabled`) and the usual migration friction is absent — **zero** files
|
||
export `dynamic`, `revalidate` or `fetchCache`, and `experimental.dynamicIO` was never set.
|
||
|
||
**First blocker, and it is cheap.** Everything is routed through a `[locale]` dynamic root segment, so
|
||
the build stops before prerendering: `A required root parameter (locale) was not provided in
|
||
generateStaticParams`. That looks like 53 routes × 38 locales = 2,014 shells, but it is not: the guide
|
||
is explicit that `generateStaticParams` need only return **one** value, because with Cache Components
|
||
`dynamicParams: true` serves the App Shell for unlisted params and upgrades it in the background. One
|
||
entry for `en` clears it.
|
||
|
||
**Second blocker, and it is structural.** Past that, 111 routes fail with the *same* error:
|
||
|
||
```
|
||
Error occurred prerendering page "/en/manage/settings"
|
||
SqliteError: no such table: serverSetting
|
||
```
|
||
|
||
Prerendering runs at build time, and the root layout unconditionally awaits real data:
|
||
|
||
```ts
|
||
const [session, user, serverSettings, colorScheme] = await Promise.all([
|
||
sessionPromise, userPromise, getRscServerSettingsAsync(), getCurrentColorSchemeAsync(),
|
||
]);
|
||
```
|
||
|
||
The immediate cause is mundane: no migration had been run, so the build had no tables. That is fixable
|
||
— run the migration first and the build gets a database.
|
||
|
||
**But making the error go away that way would be the wrong fix, and the error is the useful signal.**
|
||
If a build needs the database in order to prerender a route, the route's content is being decided at
|
||
build time rather than per request. For Homarr that is actively wrong: `serverSetting` is
|
||
per-installation data on the user's own volume, so a shell prerendered against the build machine's
|
||
database would bake one install's settings into every other install's first paint. The error is Next.js
|
||
correctly reporting that a request-time read sits in the shell path.
|
||
|
||
The honest fix is therefore to defer those reads into `<Suspense>`, not to feed the build a database.
|
||
And that is where it stops being worth it: all four values feed providers that wrap `children`
|
||
(`AuthProvider session=`, `SettingsProvider user=`), so deferring them puts essentially the whole app
|
||
inside the boundary and leaves a static shell of little more than `<html><body>`. The build would
|
||
report `◐` while shipping an empty shell — precisely the failure the migration guide warns about.
|
||
|
||
**And the prize is small.** Measured against a running container, warm, five repeats:
|
||
|
||
| request | TTFB |
|
||
| --- | --- |
|
||
| `/api/health/live` (skips the root layout) | ~3 ms |
|
||
| `/auth/login` (through the root layout) | ~19–24 ms |
|
||
|
||
So the layout's blocking work — auth, two DB reads, a cookie read — is worth **~16–21 ms**. Not
|
||
perceptible, against a root-layout restructure affecting all 53 routes plus a standing constraint on
|
||
every route added afterwards.
|
||
|
||
**The main screen cannot benefit at all.** `DynamicClientBoard` is already
|
||
`dynamic(..., { ssr: false })` with a skeleton `loading` shell, so the board already paints an instant
|
||
shell and renders on the client. Cache Components has no server render to unblock there; the board's
|
||
real cost is client JS and tRPC round-trips, which is what the rest of this work addresses.
|
||
|
||
**Two honest caveats.** The ~20 ms is a warm local SQLite install; on a network database (MySQL,
|
||
Postgres) or slow storage those four awaits could cost far more, and the win would scale with them.
|
||
And the useful idea inside this — *do not block the first byte on auth plus two DB queries* — can be
|
||
had without the migration, by streaming those reads in the existing layout. That is a much smaller,
|
||
independently testable change and is the shape worth trying if first paint ever needs attention.
|
||
|
||
## `optimizePackageImports` — inert, because this app builds with Turbopack
|
||
|
||
Seven Mantine entry points were absent from `experimental.optimizePackageImports` while
|
||
`@mantine/core`, `@mantine/hooks` and `@tabler/icons-react` were listed: `charts`, `dates`,
|
||
`dropzone`, `form`, `notifications`, `spotlight`, `tiptap`. Adding all seven and rebuilding:
|
||
|
||
| | baseline | all seven added |
|
||
| --- | --- | --- |
|
||
| client chunk files | 482 | 482 |
|
||
| client chunk bytes | 18050.7 KiB | 18050.7 KiB |
|
||
| largest chunk | 2341.5 KiB | 2341.5 KiB |
|
||
|
||
**Byte-for-byte identical, down to the chunk hashes.** That pattern normally means a cached build — and
|
||
`turbopackFileSystemCacheForBuild` is enabled here — so it was checked rather than written up as a null
|
||
result. The Next.js documentation is explicit: *"This is not needed when using Turbopack. Turbopack
|
||
automatically analyzes and optimizes imports without requiring this configuration."* The build banner
|
||
reads `Next.js 16.3.0-preview.9 (Turbopack)`.
|
||
|
||
So the existing three-entry list is also dead config. It is left in place as a fallback should the app
|
||
ever build with webpack again, with a comment saying not to expect anything from it.
|
||
|
||
This retires the entire "barrel imports / bundle optimization" line of enquiry for this repo, including
|
||
the sub-question of whether `@homarr/ui` — the biggest barrel at 111 importers — could be optimised.
|
||
It cannot be by that mechanism, and does not need to be.
|
||
|
||
Two measurement notes for anyone revisiting this: Next 16 with Turbopack prints **no size columns** in
|
||
its route table and emits no `app-build-manifest.json`, so per-route first-load JS is not available
|
||
from the build output. Total bytes under `.next/static/chunks` is what these numbers are.
|
||
|
||
## `--jitless` — looked like the biggest win of the entire investigation, and was fake
|
||
|
||
V8's `--jitless` removes the optimising compiler and all generated machine code. Its effect on code
|
||
memory is real and unambiguous: **code space 11.6 → 0.0 MiB**. The stress figures looked
|
||
spectacular: peak node RSS −24%, peak container −36%, CPU −14%, median board load **30% faster**.
|
||
|
||
Three of those are impossible. The flag was applied to the **server**, so it cannot make the browser
|
||
lighter, and removing an optimising compiler does not make code faster.
|
||
|
||
| | normal | jitless |
|
||
| --- | --- | --- |
|
||
| DOM nodes | 8,256 / 8,280 | **5,442 / 5,442** |
|
||
| JS event listeners | 7,781 / 7,784 | **1,570 / 1,570** |
|
||
| browser JS heap | 50.0 / 49.5 MiB | 28.1 / 28.2 MiB |
|
||
|
||
Boards still reported 28/28 widgets ready, so the coarse check passed — but the page carries **80%
|
||
fewer event listeners**. Much of the UI never mounted. The memory "win" is an application doing less
|
||
work, and the CPU and latency "improvements" are the same artefact.
|
||
|
||
## `--max-opt=2` (cap V8 at Maglev) — null result, and it closes the door
|
||
|
||
Since full jitless is unusable, capping the *tier* while still generating code was the obvious next
|
||
try. `--max-opt` is rejected by `NODE_OPTIONS` and accepted on the command line.
|
||
|
||
| stage | full | Maglev-capped |
|
||
| --- | --- | --- |
|
||
| peak anon | 276.0 MiB | 282.8 MiB (+2%) |
|
||
| boot idle | 64.0 MiB | 72.3 MiB (+13%) |
|
||
| dashboard loaded | 186.1 MiB | 179.2 MiB (−4%) |
|
||
| 4 tabs | 270.8 MiB | 238.5 MiB (−12%) |
|
||
| after soak | 199.0 MiB | 214.3 MiB (+8%) |
|
||
| CPU | 28.1 s | 27.8 s |
|
||
|
||
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 — there is simply no
|
||
consistent win.
|
||
|
||
The mechanism closes it permanently. 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 the page.
|
||
|
||
## jemalloc — the hypothesis was backwards
|
||
|
||
~38 MiB of V8 compiler zone memory is freed back to malloc and never returned to the kernel by musl,
|
||
so an allocator that decays unused 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.
|
||
|
||
| stage | musl | jemalloc |
|
||
| --- | --- | --- |
|
||
| boot idle | 62.4 MiB | **80.3 MiB (+29%)** |
|
||
| after sign-in | 144.9 MiB | **170.6 MiB (+18%)** |
|
||
| dashboard loaded | 214.8 MiB | 184.4 MiB (−14%) |
|
||
| 4 tabs | 259.9 MiB | **319.6 MiB (+23%)** |
|
||
| after soak | 215.5 MiB | **279.6 MiB (+30%)** |
|
||
| **CPU** | 28.0 s | **24.5 s (−13%)** |
|
||
|
||
jemalloc does not release freed spans sooner; it deliberately retains more of them (thread-local
|
||
caches, time-based dirty-page decay) and spends that memory on speed. **musl is already the frugal
|
||
choice**, so "swap the allocator" is not a RAM direction. If CPU ever becomes the binding
|
||
constraint, this is a measured 13% for two lines.
|
||
|
||
## Lazy-loading mysql2 and pg — 21.4 MiB, three broken attempts
|
||
|
||
Both load on every SQLite install, because `db/drivers/index.ts` imports all three driver wrappers
|
||
statically and picks one at call time. The prize is 21.4 MiB. Three attempts, each caught only by
|
||
booting the real image:
|
||
|
||
1. `createRequire(import.meta.url)` is `undefined` in the CommonJS `migrate.cjs` bundle. It threw at
|
||
load and broke startup **for every dialect, including SQLite**.
|
||
2. With that fixed, MySQL failed with `MODULE_NOT_FOUND` on `drizzle-orm/mysql2/index.cjs` — a
|
||
dynamic require resolves through the package's `require` condition to a `.cjs` build that Next's
|
||
tracing never copies into the standalone output.
|
||
3. Leaving drizzle's dialect static defeats the purpose: `drizzle-orm/mysql2` imports `mysql2`
|
||
itself, so the client loads anyway. Verified — the built image still loaded all 77 mysql2 files.
|
||
|
||
The remaining routes are worse than the problem: forcing drizzle's `.cjs` into the image loads a
|
||
**second copy of drizzle's core** beside the ESM one (straddled class identities), and the
|
||
`require(esm)` route works only via a hardcoded absolute path, so a drizzle upgrade would break
|
||
*startup* on two dialects. Structural blocker: `createDb` must stay synchronous because
|
||
`packages/db/index.ts` does `export const db = createDb(schema)` at module scope and `migrate.cjs` is
|
||
CommonJS.
|
||
|
||
## Coalescing Beszel subscription updates — measured exactly zero
|
||
|
||
Three Beszel widgets account for 82% of idle DOM churn, so batching their per-record `setState` calls
|
||
looked like an easy win. Implemented, built, measured:
|
||
|
||
| | before | after |
|
||
| --- | --- | --- |
|
||
| DOM mutations / 45 s | 733 | 748 |
|
||
| React commits / 45 s | **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 — arithmetic that should have come before the implementation.
|
||
|
||
It also exposed a unit error: **DOM mutations are not re-renders**. The real rates are **1.22 React
|
||
commits per second** for the whole board and **13.3 DOM mutations per commit**. So the stats widget's
|
||
"10.4 mutations/second" is about 0.8 *renders* per second: three live-monitoring widgets each
|
||
refreshing about once a second, which is what they exist to do. **73 commits/min is the feature
|
||
working, not a defect.**
|
||
|
||
## Next's image optimiser — 18.5 MiB shipped, 0 MiB of RAM
|
||
|
||
sharp plus `@img` is 18.5 MiB in the image and costs **+9.1 MiB RSS** once `/_next/image` is hit. But
|
||
`shouldUseNextImage={true}` appears nowhere, so the logo always renders a plain `<img>`, and across
|
||
three full 4-tab stress runs sharp **never loads**. `images: { unoptimized: true }` would save no RAM
|
||
at all. The 9.1 MiB is conditional on visiting the About or Kubernetes pages; the 18.5 MiB is an
|
||
image-size observation, not a memory one.
|
||
|
||
## Smaller null results
|
||
|
||
| idea | measured | verdict |
|
||
| --- | --- | --- |
|
||
| base64 images in cache / TanStack Query / Redis | **0.01 MiB** (2 data-URLs), Redis totals 2.17 MB, `image-proxy` stores URL + headers only | the theory that started this — not a factor |
|
||
| orphaned zlib streams | `Zlib=0` handles before and after 300 aborted gzip requests | Next's leak class does not occur here; nginx buffers the response so Next never sees the disconnect |
|
||
| V8 native contexts (2 → 10 under load) | **0.25 MiB each**, ~2.0 MiB total | too small to chase; sized before investigating |
|
||
| Redis 6 → 2 client consolidation | 139.3 vs 139.2 MiB idle | inside noise, reverted. The `pubSub:last:*` TTL was kept as a real unbounded-growth fix |
|
||
| V8 old-space cap | removing it entirely matched the capped build at every stage | a drift ceiling, not a reduction. Kept as one line |
|
||
| `--max-semi-space-size=2` (vs 4) | no improvement | 4 is the sweet spot |
|
||
| `--optimize-for-size`, `--no-opt`, `--lite-mode` | rejected by Node in `NODE_OPTIONS` | not available as a runtime knob |
|
||
| in-image Redis / nginx removal | 8.1 / 9.2 MiB RSS (3.1 PSS for nginx) | real but small, and both are load-bearing |
|
||
| ASCII-escaping server bundles | **9.45 MiB** — one char above U+00FF forces a whole source string to 2 bytes/char, proven at 2.00 vs 1.00 bytes/char | genuine, but it means rewriting minified **vendor** output at build time; deserves its own PR |
|