build: serve the frontend with Caddy, and add a homepage perf harness #21

Merged
me merged 4 commits from perf/homepage-first-paint into main 2026-08-29 18:23:48 +00:00
Owner

The frontend container served everything uncompressed and uncacheable. This fixes that, and adds the harness that found it.

Confirmed against production before changing anything - the live entry bundle came back at content-length: 187149 even when asked for gzip, br, zstd, with no content-encoding, no cache-control and no vary.

Caddy replaces nginx

The old nginx.conf set no compression and no cache headers. Caddy adds zstd with a gzip fallback, and marks the fingerprinted /assets/* immutable while keeping index.html on no-cache. Still rootless, still port 8080, TLS still terminated upstream. The config is also about a third the size.

Verified on the built image: runs as uid 1000, serves Content-Encoding: zstd with Vary: Accept-Encoding, Cache-Control: public, max-age=31536000, immutable on assets, and the SPA fallback intact.

before after
Entry JS over the wire 182 KB 60 KB
CSS over the wire 167 KB 33 KB
First contentful paint 2760 ms 1200 ms
DOMContentLoaded 2713 ms 1118 ms
Largest contentful paint 7156 ms 4560 ms

Two small things alongside it

index.html gets three lines of inline CSS naming the background. The stylesheet blocks the first paint, so without this the browser shows white until the bundle lands - on a dark app that reads as a flash of the wrong thing.

main.tsx drops the hydrateRoot branch left over from the prerender deleted in 6a569e2. Nothing writes markup into #root any more, so that path was dead.

The harness

frontend/perf/homepage-loop.mjs produced every number above. It serves the build over a local static server that gzips exactly like the Caddyfile, cold-loads it under pinned Slow-4G and 4x CPU throttling, and asserts byte and timing budgets. Byte counts are identical run to run; timings vary ~1%. Runs in about five seconds and needs no backend.

node perf/homepage-loop.mjs                    # exit 1 on breach
node perf/homepage-loop.mjs --report           # measure without gating
node perf/homepage-loop.mjs --url http://...   # measure a real server

Budgets are ratchets on today's measurements, not aspirations - lower them when something genuinely improves. Not wired into CI in this PR.

What this PR deliberately does not do

An earlier version of this branch added a boot shell: hand-written splash markup in index.html plus two Vite plugins, painting at 348ms instead of 1200ms. It was measured against an uncompressed baseline, and once Caddy landed most of its value evaporated - it bought ~850ms of earlier pixels while making DOMContentLoaded and LCP marginally worse, in exchange for a duplicated splash that had to be kept in step with SplashScreen.tsx. Measured three ways and removed. Without it the app paints its own real splash at ~1.2s, which is the correct component doing its own job.

Also left alone:

  • LCP is ~4.6s - the hero h1 animates in over several seconds. Loading is no longer the constraint; the animation is. Its own problem.
  • The router is gated behind auth init, so the Home chunk is not requested until /api/auth/refresh/ returns - worth ~420ms, more against a real backend, but it changes behaviour for signed-in visitors.
  • docs/adr/0002 is stale - it records build-time pre-rendering for /, which 6a569e2 reversed.

Verification

tsc clean, 71 tests pass, both --mode production and --mode e2e build, harness green.

The frontend container served everything uncompressed and uncacheable. This fixes that, and adds the harness that found it. Confirmed against production before changing anything - the live entry bundle came back at `content-length: 187149` even when asked for `gzip, br, zstd`, with no `content-encoding`, no `cache-control` and no `vary`. ## Caddy replaces nginx The old `nginx.conf` set no compression and no cache headers. Caddy adds zstd with a gzip fallback, and marks the fingerprinted `/assets/*` immutable while keeping `index.html` on `no-cache`. Still rootless, still port 8080, TLS still terminated upstream. The config is also about a third the size. Verified on the built image: runs as uid 1000, serves `Content-Encoding: zstd` with `Vary: Accept-Encoding`, `Cache-Control: public, max-age=31536000, immutable` on assets, and the SPA fallback intact. | | before | after | |---|---|---| | Entry JS over the wire | 182 KB | **60 KB** | | CSS over the wire | 167 KB | **33 KB** | | First contentful paint | 2760 ms | **1200 ms** | | DOMContentLoaded | 2713 ms | **1118 ms** | | Largest contentful paint | 7156 ms | **4560 ms** | ## Two small things alongside it **`index.html`** gets three lines of inline CSS naming the background. The stylesheet blocks the first paint, so without this the browser shows white until the bundle lands - on a dark app that reads as a flash of the wrong thing. **`main.tsx`** drops the `hydrateRoot` branch left over from the prerender deleted in 6a569e2. Nothing writes markup into `#root` any more, so that path was dead. ## The harness `frontend/perf/homepage-loop.mjs` produced every number above. It serves the build over a local static server that gzips exactly like the Caddyfile, cold-loads it under pinned Slow-4G and 4x CPU throttling, and asserts byte and timing budgets. Byte counts are identical run to run; timings vary ~1%. Runs in about five seconds and needs no backend. ``` node perf/homepage-loop.mjs # exit 1 on breach node perf/homepage-loop.mjs --report # measure without gating node perf/homepage-loop.mjs --url http://... # measure a real server ``` Budgets are ratchets on today's measurements, not aspirations - lower them when something genuinely improves. Not wired into CI in this PR. ## What this PR deliberately does not do An earlier version of this branch added a boot shell: hand-written splash markup in `index.html` plus two Vite plugins, painting at 348ms instead of 1200ms. It was measured against an uncompressed baseline, and once Caddy landed most of its value evaporated - it bought ~850ms of earlier pixels while making DOMContentLoaded and LCP marginally *worse*, in exchange for a duplicated splash that had to be kept in step with `SplashScreen.tsx`. Measured three ways and removed. Without it the app paints its own real splash at ~1.2s, which is the correct component doing its own job. Also left alone: - **LCP is ~4.6s** - the hero `h1` animates in over several seconds. Loading is no longer the constraint; the animation is. Its own problem. - **The router is gated behind auth init**, so the Home chunk is not requested until `/api/auth/refresh/` returns - worth ~420ms, more against a real backend, but it changes behaviour for signed-in visitors. - **`docs/adr/0002` is stale** - it records build-time pre-rendering for `/`, which 6a569e2 reversed. ## Verification `tsc` clean, 71 tests pass, both `--mode production` and `--mode e2e` build, harness green.
me added 2 commits 2026-08-29 17:52:10 +00:00
build: serve the frontend with caddy for compression and asset caching
Some checks failed
CI / Frontend CI (pull_request) Successful in 59s
CI / Backend CI (pull_request) Successful in 54s
CI / E2E Tests (pull_request) Has been cancelled
dda0cc64d4
fix: hold the boot shell until a route renders so the splash never swaps
All checks were successful
CI / Frontend CI (pull_request) Successful in 52s
CI / Backend CI (pull_request) Successful in 50s
CI / E2E Tests (pull_request) Successful in 5m46s
9616306bb3
perf: drop the boot shell now that compression covers the blank window
All checks were successful
CI / Frontend CI (pull_request) Successful in 53s
CI / Backend CI (pull_request) Successful in 51s
CI / E2E Tests (pull_request) Successful in 5m36s
99fe380694
me changed title from perf: kill the homepage blank screen, and compress what follows to build: serve the frontend with Caddy, and add a homepage perf harness 2026-08-29 18:10:11 +00:00
me merged commit 1407270cb4 into main 2026-08-29 18:23:48 +00:00
me deleted branch perf/homepage-first-paint 2026-08-29 18:23:48 +00:00
me referenced this pull request from a commit 2026-08-29 18:23:49 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
me/pi-ku!21
No description provided.