Loading one 13 KB page on our site fetches 37 KB of pages nobody asked for, in 21 requests for 7 destinations
Our marketing site is 72 URLs, almost all of them prerendered, with a header and a footer that link to the same handful of hubs from every page. It is about the most boring hosting situation there is, which is why I was surprised by what one page load actually costs.
I opened one of our guide pages with the HTTP cache disabled and read performance.getEntriesByType('resource') back out:
| What | Requests | Bytes over the wire |
|---|---|---|
| The page itself | 1 | 13,107 (78,764 decoded) |
| JavaScript chunks | 19 | 379,011 |
| Prefetches of other pages | 21 | 36,941 |
The page that was asked for is 13 KB compressed. The pages that were not asked for are 37 KB, which is nearly three times as much, in 21 separate requests.
21 requests, 7 destinations
Every one of those 21 URLs is distinct, and every one was fetched exactly once. The paths are not distinct at all:
/guides?_rsc=Ox8cgg0DhsyxPrjc919 B/guides?_rsc=eR12wK0yiz95Oty81,349 B/guides?_rsc=RSGG3-1_3tJSZq1R1,713 B/guides?_rsc=gRC1URwIpb1MAZjq707 B/guides?_rsc=tzD_JKmir6H8FJjb5,685 B/solutions?_rsc=...(4 tokens, 707 B to 6,196 B)/features?_rsc=...(4 tokens, 707 B to 7,301 B)/?_rsc=...(2 tokens)/signup?_rsc=...(2 tokens)/login?_rsc=...(2 tokens)/pricing?_rsc=...(2 tokens)
Seven destinations, 21 responses. One token value, Ox8cgg0DhsyxPrjc, is shared by seven of the requests, and all seven of those came back small: between 404 and 922 bytes. The large responses each have a token of their own.
So the token is not a hash of where the request is going. It is a hash of the router state it is going from, which is why the same destination is fetched several times over with different tokens, and why the same path can answer with 707 bytes to one of them and 7,301 bytes to another. The small ones are slices. The big ones are the whole page.
You can see what the big ones are with curl. Ask any page for its React flight payload instead of its HTML:
curl -s -H "RSC: 1" https://pub-trivia.app/faq | head -c 60
1:"$Sreact.fragment"
curl -sI -H "RSC: 1" https://pub-trivia.app/faq
content-type: text/x-component
x-nextjs-prerender: 1
x-nextjs-stale-time: 180
x-vercel-cache: HIT
That payload is 40,211 bytes for our FAQ against 78,013 bytes of HTML for the same page. A touch over half the size, and a complete copy of the page's content either way.
Why a prefetch here is a whole page
A prefetch is allowed to stop at the nearest loading boundary and fetch only the shell. There is no boundary to stop at on any public page of this site.
$ find app -name loading.tsx
app/dashboard/billing/loading.tsx
app/dashboard/help/loading.tsx
...
11 files, every one of them under app/dashboard
Eleven loading.tsx files, all behind the sign-in. Zero on the 72 URLs a visitor can reach. The dashboard is where a spinner is worth writing, because those pages query a database; the marketing pages are prerendered files, so a skeleton would be a loading state for something that is already sitting on a CDN edge.
The side effect is that the router has nothing partial to ask for out here, so hovering a footer link downloads the destination's entire content.
What the CDN is keyed on
Every HTML and flight response from the site carries this:
vary: rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch
Four request headers, none of which a human ever sends, each one capable of changing the response. One URL is therefore many cache objects, and Vary on four custom headers is the sort of cache key intermediaries handle badly or ignore. That is what the ?_rsc= token in the query string is for: it turns each variant into its own URL, which every cache in the world already knows how to key on.
Two more headers come along for the ride:
x-nextjs-stale-time: 180
x-nextjs-deployment-id: dpl_EKzaTAmQUBUdseoodG43waJqVSuS
The first is our own next.config.mjs echoed back at us, staleTimes: { dynamic: 30, static: 180 }, telling the client router it may reuse a prefetched payload for three minutes before asking again. So the 37 KB is not spent per hover, it is spent per three-minute window, which is the thing that makes the whole design defensible.
The second is deployment pinning, and it is everywhere once you look for it. dpl=dpl_ appears 84 times in the HTML of our FAQ page and 60 times inside its flight payload, because every asset URL carries it:
/_next/static/chunks/2-o5o5fwmt4ld.js?dpl=dpl_EKzaTAmQUBUdseoodG43waJqVSuS
A page rendered by one deployment therefore always loads chunks from that same deployment, even if we ship a new one while somebody is reading. It costs 33 characters per asset URL, and it is the reason a deploy mid-session does not hand anybody a half-updated bundle.
One aside on compression, because I measured it anyway
The same page, requested four ways:
identity78,013 bytesgzip12,218 bytesbrotli12,268 byteszstd78,013 bytes
Brotli is 50 bytes bigger than gzip on this document, which is a reminder that br beats gzip on average and not on every file. Asking for zstd gets nothing: the edge does not offer it, so the request falls back to uncompressed.
Our next.config.mjs sets compress: true, which on this host is decoration, since the edge is making all four of those decisions and the Node server is not in the path.
What I am going to do about it
Probably nothing to the prefetching of the header links, which are the pages a visitor is most likely to go to next, and something about the footer. The footer repeats most of those destinations, and the duplication in that list of 21 is largely it: /features and /solutions and /guides are each linked from both.
A prefetch={false} on the footer copies would cut the 21 requests to roughly 7 without touching the links a reader actually hovers over.
The wider point is that prefetching is tuned for apps, where the next page is a data fetch away and the user is going to click something. A marketing page where most sessions are one page long inverts the economics, and the default is to spend three times the page's own weight on pages nobody opens.
If you want to watch it happen, open the Network tab, filter on _rsc, and load any page on pub-trivia.app. Twenty-one rows, seven destinations, and not one of them requested by the reader.
Comments
No comments yet. Start the discussion.