From ca0bb36664554755d5a5067817ca2835d63d907f Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Fri, 14 Aug 2026 11:11:50 +0200 Subject: [PATCH] docs(perf): Cache Components tested and rejected, with the numbers Enabled cacheComponents: true on Next 16.3.0-preview.9 and built. The flag works and the usual migration friction is absent - zero files export dynamic, revalidate or fetchCache. Two blockers. The [locale] dynamic root segment needs generateStaticParams, which is cheap: the guide is explicit that one value suffices, because dynamicParams: true now serves the App Shell for unlisted params and upgrades in the background. Not 53 routes x 38 locales. Past that, 111 routes fail with the same error - SqliteError: no such table: serverSetting. Prerendering runs at build time and the root layout unconditionally awaits auth, two DB reads and a cookie read. For a self-hosted app whose database is created at runtime on the user's volume there is nothing to query during a build. Deferring those reads means moving the providers that wrap children inside Suspense, which leaves a static shell of little more than - the exact failure the migration guide warns about. And the prize is ~16-21 ms: measured TTFB is ~3 ms for a route that skips the root layout against ~19-24 ms for one that goes through it. Meanwhile the board - the main screen - is already dynamic(..., { ssr: false }) with a skeleton, so it already paints an instant shell and Cache Components has no server render to unblock there. Recorded with both caveats: the ~20 ms is warm local SQLite and would be larger on a network database, and the useful idea inside this - not blocking the first byte on auth plus two DB queries - can be had by streaming those reads in the existing layout, without the migration. Experiment fully reverted; no code changes in this commit. --- docs/perf/REJECTED.md | 57 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/docs/perf/REJECTED.md b/docs/perf/REJECTED.md index 38179c976..522ae7ad5 100644 --- a/docs/perf/REJECTED.md +++ b/docs/perf/REJECTED.md @@ -3,6 +3,63 @@ 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(), +]); +``` + +For a self-hosted app whose database is created at runtime on the user's volume, there is nothing to +query during a build. Fixing it means deferring those reads into `` — but all four 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 +``. The build would report `◐` while shipping an empty shell, which is 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