A single search box on one home page was backed by 255,881 characters of serialized records. The box matches on titles. 106,706 of those characters were fields it can never display.
I crawled the home page of 114 sites in one content network, captured the raw HTML before any JavaScript ran, and measured how much of that HTML was Next.js App Router's inline Flight payload — the self.__next_f.push(...) calls that carry the React Server Component tree to the client.
30 of the 114 sites ran App Router. Across those 30 home pages, 48.2% of the combined HTML was Flight payload, and 3.5% was visible text. For comparison, the 84 sites that shipped plain server-rendered HTML landed at 16.5% visible text.
That gap is the whole story, and it has two different causes. Both are measurable, and both are fixable without abandoning the framework.
How the measurement works
No headless browser, no Lighthouse. Just urllib and a regex against the raw response, so every number below can be reproduced by anyone with curl.
The Flight payload in an App Router HTML response arrives as a series of calls:
<script>self.__next_f.push([1,"1:\"$Sreact.fragment\"\n2:I[35176,[\"/_next/static/chunks/…\"],\"\"]\n…"])</script> Each argument is a JSON string. Concatenating them in document order reconstructs the RSC tree as text. Then:
- payload characters = length of that concatenation
- visible text =
<body>minus<script>,<style>,<noscript>,<svg>, tags collapsed, whitespace normalized - structural HTML = the document with every
<script>block removed
The third number matters more than the first two. It tells you what the page actually painted, independent of what it also shipped for the client.
The 30 sites, by the numbers
| Metric | Value |
|---|---|
| Median HTML | 133,637 bytes |
| Median visible text | 4,801 characters |
| Median text share | 3.3% |
| Total HTML (30 pages) | 4,697,963 characters |
| Total Flight payload | 2,265,107 characters (48.2%) |
| Payload share, median site | 49.3% |
| Sites above 50% payload | 13 of 30 |
| Payload after gzip | 334,172 characters (85.2% compression) |
className occurrences | 11,694, of which 3,168 unique |
Serialized children nodes | 12,566 |
Against the 84 server-rendered sites: median HTML 50,586 bytes, median visible text 4,510 characters, median text share 16.5%.
The visible text is roughly the same. The markup around it is 2.6× larger. That is not a content difference — it is a serialization difference.
You can see the other end of the range in a broker comparison page that serves 2.4 KB compressed and still fills a screen. Nothing about that page is unmaintainable; it just never had a component tree written into it twice.
Failure mode 1: the payload is data, and most of the data is unused
marketpredictionnow.com is the extreme case. Its home page is 666,510 bytes, of which 281,038 characters are Flight payload — 65.2% of the document. Strip the <script> blocks and 118,007 characters of markup remain. Visible text: 5,939 characters, or 1.4% of the page.
Inside that payload sit 300 complete content records, 255,881 characters total. Each record carries 19 fields: slug, title, excerpt, seoTitle, seoDescription, background, bullCase, bearCase, watchMetrics, tags, deadline, published, updated, category, entity, evidenceLevel, searchIntent, editorScore, scoreTrend.
The page renders 27 of them as cards. I checked all 300 titles against the script-free HTML: 27 matched, 273 did not. Those 273 records occupy 233,157 characters that never reach the document.
The obvious defence is the search box — and there is one, a single <input> with a placeholder reading "搜索比特币、AI、英伟达……". So the records are not gratuitous. But a title search does not need this:
| Field | Rendered | Characters |
|---|---|---|
seoDescription | 0 / 300 | 28,884 |
bullCase | 0 / 300 | 14,705 |
watchMetrics | 0 / 300 | 13,523 |
bearCase | 0 / 300 | 13,158 |
tags | 0 / 300 | 12,811 |
seoTitle | 0 / 300 | 12,301 |
published + updated | 0 / 300 | 7,200 |
searchIntent | 0 / 300 | 3,080 |
evidenceLevel | 0 / 300 | 1,044 |
That is 106,706 characters — 42% of all record data — for fields that are neither rendered nor searchable in any useful sense. seoDescription alone is 28,884 characters of meta description text sent to a browser that will never display it; its only consumer is the crawler, and the crawler already had it in the <meta> tag.
The fields a client-side title filter can actually use are title (8,321), slug (8,052) and category (2,055): 18,428 characters, or 7.2% of what was shipped.
import json, re REC = re.compile(r'\{"slug":"[^{}]*?"searchIntent":"[^"]*"\}') def records(payload): return [json.loads(m) for m in REC.findall(payload)] recs = records(payload) usable = ("title", "slug", "category") needed = sum(len(json.dumps({k: r[k] for k in usable}, ensure_ascii=False)) for r in recs) total = sum(len(json.dumps(r, ensure_ascii=False)) for r in recs) print(f"{needed:,} of {total:,} = {100*needed/total:.1f}%") # 18,428 of 255,881 = 7.2% Those records are also a real content library, and a single article page is where that library should be read — one SpaceX competitor analysis rendered on its own route does the same job without carrying the index with it.
Failure mode 2: the payload is markup, and the markup is repetitive
The second pattern has no data in it at all. cryptogemradar.com ships 131,295 characters of payload on a 254,845-byte home page, and it contains 1,184 className values — 110 of them unique. A single string, text-primary, appears 177 times. flex items-center gap-2 whitespace-nowrap text-sm appears 168 times.
openaistockforecast.com is the same shape: 184,049 characters of payload, 906 className values across 138 unique ones, with text-sm text-muted-foreground transition-colors hover:text-foreground repeated 83 times. Its payload also carries long RSC path strings like $e:props:children:0:props:children:1:props:children:props:children:0:props:iconNode, which exist purely to address nodes in the tree being serialized.
Across all 30 sites, 11,694 className occurrences collapse to 3,168 unique strings. Deduplicating them would remove 495,736 characters — 21.9% of the total payload — without changing a single pixel.
This is what the serialization is actually spending its budget on: not content, not state, but the styling of decorative containers, written out again for each instance.
gzip hides this, and that is the trap
The payload compresses at 85.2%. 2.27 million characters become 334 KB across 30 pages — about 11 KB per page over the wire. At that size, nobody's Core Web Vitals dashboard moves, which is exactly why this survives for years.
What gzip does not compress away:
- Parse cost. 131,295 characters of Flight text still has to be parsed and walked to build the tree.
marketpredictionnow.comasks the browser to materialize 12,566childrennodes for a page that draws 27 cards. - The unused 42%. Meta descriptions and bull/bear cases that never render are not made cheaper by being compressed; they are just smaller waste.
- Cacheability. Every one of these responses came back
cf-cache-status: DYNAMICwith noCache-Controlheader, so the HTML — payload included — is regenerated and re-sent on every request instead of being served from the edge.
That last point is the one I would fix first. A page whose content changes daily does not need to be re-serialized on every visit.
A CI check you can run in ten seconds
You do not need to crawl 114 sites to catch this. One request per route in your own build is enough:
import gzip, json, re, urllib.request PUSH = re.compile(r'self\.__next_f\.push\(\[1,("(?:[^"\\]|\\.)*")\]\)', re.S) def audit(url, budget=0.4): req = urllib.request.Request(url, headers={"Accept-Encoding": "gzip", "User-Agent": "payload-budget/1.0"}) raw = urllib.request.urlopen(req, timeout=20).read() html = gzip.decompress(raw).decode("utf-8", "replace") if raw[:2] == b"\x1f\x8b" else raw.decode("utf-8", "replace") payload = "".join(json.loads(m) for m in PUSH.findall(html)) body = re.search(r"<body\b[^>]*>(.*)</body>", html, re.S | re.I).group(1) text = re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", re.sub(r"<(script|style|svg)\b[^>]*>.*?</\1>", " ", body, flags=re.S | re.I))).strip() share = len(payload) / len(html) return {"url": url, "html": len(html), "payload": len(payload), "share": round(share, 3), "text": len(text), "fail": share > budget} Wire it into the build and fail when share crosses 0.4. On the 30 sites above, 13 would have failed. Pick the budget from your own baseline rather than from mine — a dashboard with real client state has a legitimate reason to carry a large tree; a static article page does not.
Four fixes, in order of payoff
Trim the query, not the framework. If a route serializes N records, select only the columns the rendered view and the client filter need. On marketpredictionnow.com that alone removes 106,706 characters, and the search box still works because it only ever matched on titles.
Paginate the index. Inlining 300 records to render 27 is a 10:1 ratio. Inline the 27, fetch the rest on interaction. The full set belongs on the index route, not the home page — a DeFi trends article delivered as one document carries no listing data at all.
Let the edge cache. Set an explicit Cache-Control with a short s-maxage and a long stale-while-revalidate. A 666 KB document that is regenerated per request is the expensive part, not the 15 KB of compressed payload inside it.
Stop serializing decorative class strings. If a component's only prop is a Tailwind string, it does not need to round-trip through Flight. This is the 495,736 characters that deduplicate to nothing.
What this measurement does not prove
The payload is not dead weight by design. On an App Router page it is what lets a client-side navigation reuse an already-loaded tree instead of issuing a fresh round trip. On a site where visitors click from page to page, that is a genuine win, and I am not arguing Next.js is the wrong choice for these 30 sites — the 84 server-rendered ones have their own failure modes, several of which I have written about separately.
What the numbers do show is narrower and harder to argue with: on pages that are read once and left, roughly half the document is a second copy of the page, and on one page 106,706 characters of that copy described a dataset whose only consumer was a single search input.
The 84 plain sites are not a control group I chose for purity — they run the same content pipeline. They are just 2.6× lighter for the same amount of text, and that gap is entirely a serialization decision.