Topic: Web platform
Your SSR Data Is Part of the Page Weight
SSR can improve first paint and SEO, but hydration JSON and RSC payloads still travel and parse. Here is a practical Next.js measurement and reduction order.
Animated meme (expand/collapse)
Frontend performance reviews often begin and end with the JavaScript bundle: which chunk is large, whether tree shaking worked, and what can move behind a dynamic import.
Server-rendered React has another payload that is easy to miss. The Pages Router places page props in __NEXT_DATA__; the App Router sends a React Server Component payload. Neither looks like a traditional .js bundle, but both cross the network, get parsed by the browser, and participate in hydration or later navigation.
A recent daily.dev summary of RSC performance pitfalls lists large structures crossing the server-client boundary as a common problem. A Reddit thread describes an RSC request of roughly 60 kB taking two to three seconds.
Both sound like payload problems, yet they may need completely different fixes. Size controls transfer, parsing, and memory cost; waiting time may come from dynamic rendering, cache misses, cold starts, or database distance. Without separating those measurements, it is easy to delete the wrong thing.
SSR removes a client fetch, not data delivery
Server-side rendering has concrete benefits. Users receive displayable HTML, and crawlers can read content from the initial document. If the client will take over interaction, however, React must know what the server rendered.
React’s hydrateRoot attaches component logic to server-generated HTML, and the initial client render must match the server output. A framework therefore needs a data contract that can reconstruct or reconcile the view.
That contract has weight:
Document HTML
+ serialized page data or RSC payload
+ client JavaScript
+ CSS, fonts, images, and later requests
Reducing a JavaScript bundle from 300 kB to 200 kB while embedding an untouched 500 kB CMS response in the HTML is not a complete optimization. The weight merely changed luggage.
Pages Router: every prop enters the initial HTML
The Next.js getServerSideProps documentation explicitly warns that props passed to a page are visible in the client’s initial HTML regardless of rendering mode because hydration needs them. Sensitive data must never enter those props.
The Pages Router generally serializes that data into:
<script id="__NEXT_DATA__" type="application/json">
{"props":{"pageProps":{...}}}
</script>
Next.js currently emits a Large Page Data warning above 128 kB. Its documentation names three costs:
- The data is embedded in every HTML response, increasing page weight.
- React cannot hydrate until the JSON is parsed.
- The entire payload occupies client memory even when the view uses only a subset.
The 128 kB threshold is a framework warning, not a promise that 127 kB is fast. A useful budget depends on user networks, devices, interaction needs, and caching. The warning should trigger measurement, not a larger threshold.
App Router: Server Components do not mean zero payload
The App Router changes the transport and often reduces client JavaScript, but it does not eliminate data exchange between server and browser.
The Next.js Server and Client Components guide describes three parts of the first load:
- HTML presents a fast, non-interactive preview of the route.
- The RSC payload reconciles the Server and Client Component trees.
- JavaScript hydrates Client Components and adds interaction.
The RSC payload contains rendered Server Component output, placeholders and JavaScript references for Client Components, and props passed from Server Components into Client Components.
The correct advantage of a Server Component is that server-only code and dependencies stay out of the client bundle while data sources remain directly accessible. It does not mean that an entire fetched object disappears from transport. Once data becomes rendered output or crosses a 'use client' boundary as props, the browser may still receive it.
Push the Client Component boundary down. If only a favorite button is interactive, make the button a Client Component. Do not turn the entire article card and its raw CMS response into a client tree for one click handler.
First decide whether the download or the server is slow
“A 60 kB request took two seconds” does not prove that 60 kB is large. Nearly all of those two seconds might be Waiting for server response, followed by a Content Download lasting only milliseconds.
Split the timing in DevTools Network first:
| Symptom | Investigate first |
|---|---|
| Long Waiting/TTFB | Dynamic rendering, data queries, region, cold start, cache miss |
| Long Content Download | Payload size, compression, network quality |
| Fast download, late interaction | JSON parsing, hydration, client JS, main-thread work |
| Only client navigation is slow | CDN status for RSC/API route and whether prefetching worked |
Then inspect the next build route table. If a route expected to be static is marked dynamic, look for cookies(), headers(), cache: 'no-store', unresolved dynamic segments, or another request-time dependency. Do not remove visible content first; that may have no effect on the actual two-second wait.
Next.js Automatic Static Optimization makes the same distinction. A page without blocking data requirements can be prerendered as static HTML; adding getServerSideProps or getInitialProps moves it to request-time rendering. Payload size and rendering mode are separate diagnostic tracks.
Three numbers beat one Lighthouse score
I start with three budgets:
- Document transfer size: HTML bytes actually downloaded by the user.
- Serialized state size:
__NEXT_DATA__or the RSC/route payload. - Client JavaScript size: JavaScript that must download, parse, and execute.
The Pages Router makes __NEXT_DATA__ easy to measure in the console:
const json = document.querySelector("#__NEXT_DATA__")?.textContent ?? "";
new TextEncoder().encode(json).byteLength;
Measure compressed production transfer bytes with:
curl --compressed -sS -o /dev/null \
-w 'download=%{size_download} bytes\n' \
https://example.com/page
For the App Router, separate the initial Document from RSC requests made during client navigation. Record size, Waiting, Content Download, and cache headers rather than looking at the filename alone.
Compare before and after the change. “It feels lighter” without a baseline often means that the local cache happened to hit.
Animated meme (expand/collapse)
Reduction order: shrink the contract before replacing architecture
The least expensive fix usually happens before data leaves the server.
1. Select only fields that the view uses
Do not SELECT * or pass a complete CMS entry to the page and expect React to ignore the surplus. If a list card renders only id, title, slug, a thumbnail, and two status values, return those fields.
const cards = rows.map(({ id, title, slug, thumbnail, status }) => ({
id,
title,
slug,
thumbnail,
status,
}));
This projection is not type-system decoration. It defines the server-to-browser contract.
2. Paginate on the server
Sending 2,000 rows and hiding 1,980 with CSS is not pagination. The HTML, serialized data, and memory costs have already been paid. Fetch the first page first and request the next page through navigation or a user action.
3. Move 'use client' toward the interactive leaf
A Server Component can produce most static markup while passing only an id, initial state, and label to a Client Component button. Avoid sending a complete API response across the boundary because one descendant needs a click handler.
4. Defer secondary data, not primary content
Recommendations, below-the-fold statistics, or details revealed after interaction can load later. Page headings, main copy, canonical data, and content that crawlers should see belong in the initial HTML.
Moving all content into a client fetch certainly makes HTML smaller. It can also introduce a spinner, request waterfall, worse weak-network behavior, and unstable SEO. That is cost transfer, not reduction.
5. Compress text, but do not use compression as cover
The web.dev HTML performance guide recommends Brotli or gzip for text responses. Compression reduces wire bytes but does not remove decompressed JSON parsing, hydration, or memory costs.
Highly repetitive JSON may compress well enough to look small in Network. The browser still restores and parses the full payload. Keep both raw and transfer sizes.
Four common false fixes
- Raise the large-page-data threshold. The warning disappears; user cost does not.
- Move primary content to a client fetch to remove JSON. HTML shrinks and gains a waterfall.
- Migrate to the App Router by default. New architecture does not decide which data crosses a client boundary.
- Measure only compressed kilobytes. A smaller download does not make parsing, hydration, and memory free.
One more boundary is about security rather than speed: assume that users can inspect every value entering page props or Client Component props. A server function may read a secret; its full return object still cannot be handed to the client.
My diagnostic order
When an SSR page is slow or its HTML is too large, I work in this order:
- Confirm whether the route is static or dynamic.
- Split Network timing into TTFB and download.
- Measure Document, serialized state, and client JavaScript separately.
- Find the largest prop or RSC boundary before changing frameworks.
- Project fields in the query or mapper.
- Add server-side pagination to long lists.
- Retest on a weak network, lower-end device, and production cache.
Refactoring is deliberately last. Most payload problems do not need a new data layer. They need the server to stop telling the browser everything it knows.
Implementation note from this site: do not diagnose SSR where it does not exist
This blog is built from local MDX through an eager import.meta.glob in src/lib/articles.ts, and its sitemap route is prerendered before the ./dist directory is uploaded as static assets. That means article metadata and body content are resolved during the build; a normal reader visit does not receive a Next.js __NEXT_DATA__ payload or an RSC stream from this site.
The practical verification is intentionally boring:
pnpm lint
pnpm build
pnpm seo:check
Those commands prove source validity, static output generation, and this site’s sitemap and metadata assertions. They do not measure browser performance or prove that a production deployment is current. For that, inspect a live response separately after deployment.
The SSR advice in this article becomes relevant here only when a future feature adds a client island, request-time data, or a React/Next.js surface. Until then, shrinking an imaginary RSC payload would be performative optimization; the useful budget is the built HTML, CSS, media, and any JavaScript that is actually shipped.
Conclusion: HTML is a delivery format too
SSR is often treated as a box labeled “performance handled.” It only decides where the first UI is rendered; it does not decide how much data should travel.
Pages Router __NEXT_DATA__, App Router RSC payloads, and props passed to Client Components are all server-browser API contracts. Some happen to live inside HTML or a framework protocol, so they do not resemble ordinary JSON endpoints.
My default is: keep the complete content that users and crawlers need, but send only the data the browser needs to render and interact.
Measure size and waiting time first, then shrink the contract. Data does not become weightless merely because SSR produced it.
External references
- Next.js: Large Page Data
- Next.js: getServerSideProps
- Next.js: Server and Client Components
- Next.js: Automatic Static Optimization
- React: hydrateRoot
- web.dev: General HTML performance considerations
- daily.dev: 6 React Server Component performance pitfalls in Next.js
- Reddit: Next.js RSC payload requests are taking 2–3 seconds