For a while, this site could not be cached. Not by a CDN, not by a browser, not by anything — every response it produced carried cache-control: private, no-cache, no-store, so nothing anywhere was allowed to keep a copy. Every visitor re-rendered all 57 products.
The measurement that mattered was a contrast: the homepage answered in 0.49–0.95 seconds while /api/health answered in 0.08. Same host, same process. The site was not slow; it was doing work it had no reason to do.
One header, the whole tree
The cause was a single line. A proxy layer forwarded the request path into a header so the root layout could look at it and hide the site chrome on two routes. Reading a request header is a dynamic API in Next.js, and a dynamic API used in the root layout opts the entire tree out of static rendering. Every page below it became per-request work.
What makes this one interesting is the intent. Hiding chrome on /admin and /share is a trivial cosmetic requirement, and the way we solved it made the whole site uncacheable without changing a single URL or failing a single test. Cost and benefit were in different currencies, and nothing in the codebase compared them.
After the fix: 107 pages pre-rendered where none had been — 51 product pages, 14 posts, 7 pricing pages — and a homepage time-to-first-byte of 4 ms.
Then the same question, three more times
Once the pages were static, the client bundle was the obvious next target, and the answers were uncomfortable.
A 39 KB gzipped animation library was in the shared bundle of every page on the site, pulled in by the header's search modal. The homepage shipped 241 KB of JavaScript against 49 KB of HTML. Across thirteen importing files it was responsible for 29 entrance fades — which a small CSS Reveal component already did — plus two wrappers with no animation property at all, which animated nothing, one looping scroll hint, and one modal transition. That was the entire justification for the dependency, and most of it was already covered.
Next: the layout was passing the full product summary list to the header and the footer, which serialised all 57 products twice on every page — a 531 KB document. Neither component uses a cover image or a srcset; they need a small mark, a name, a tagline and a status. They now get exactly that.
Then images. Measured on the live homepage: 33 images, 845 KB, largest-contentful-paint 3.2 seconds — with a 109 KB mark and a 104 KB mark being rendered into 40-pixel slots. The asset store had been keeping a 128-pixel logo beside every 512 for months. The page just never asked for it.
The correction that mattered most
My first write-up of this said the homepage document was half a megabyte and treated that as the headline. Someone measured it properly: the document is about 500 KB of markup and transfers at 49 KB. It was never the problem.
That correction is the most useful thing in this post. The document-size number was real and the conclusion was wrong, and the difference was a distinction between what a thing contains and what it costs. We would have spent a week shrinking markup that gzip was already handling.
What we changed
The chrome decision moved out of the root layout so nothing else inherits its cost. Three separate measurements — render mode, bundle composition, and per-image bytes — replaced one impression that the site "felt slow". And /api/health at 0.08 s is now the baseline we compare every page against; when a page is thirty times slower than the health check, the page is doing something it did not mean to.
