Developer 8 min read

Your Next.js Page Ships Its Data Twice: Measuring the RSC Payload

One of our pages answered 2,318,515 bytes and 82.7% of that was props serialized into the HTML, not content. Here is how Client Component props get shipped twice, the projection that cut the page by 76%, and the measuring mistake that first told us everything was fine.

Your Next.js Page Ships Its Data Twice: Measuring the RSC Payload
Share:

If you want better results with next.js rsc payload size, this guide explains the practical steps, common mistakes, and useful browser-based tools that make the process easier.

We have an HTML sitemap page that lists every tool and article on the site. It renders about 370 links.

It was answering 2,318,515 bytes. Nothing on the page is an image, there is no embedded media, and the markup itself accounts for less than a fifth of that.

The rest was data we had already sent, serialized a second time into the HTML.

Quick Takeaways

Pro Tip

Want a faster path?

Start with HTML Minifier and then continue with Migrating pdf.js 3 to 5: The Breaking Changes Nobody Warns You About to build a practical workflow around next.js rsc payload size.

This is a property of the React Server Components architecture that is easy to read past in the docs and very hard to notice in practice, because the page looks perfect and every build, type check and lint run stays green.

Here is the mechanism, the numbers before and after, and the measuring error that first told us there was no problem at all.

The Mechanism: Props Cross the Boundary as Text

In the App Router, a Server Component can render a Client Component and pass it props. Those props have to reach the browser so React can hydrate, and the way they travel is the RSC flight payload:

a series of inline scripts in the HTML of the form self.__next_f.push([...]). Every field of every object you pass is written into that payload verbatim.

The important word is verbatim. The payload is not a diff, it is not compressed relative to the markup, and it does not contain only the fields the component reads.

If you hand a Client Component an array of 255 objects with fifteen fields each, all 255 objects and all fifteen fields are serialized, even if the component displays three of them.

So the data is on the page twice: once as the rendered markup, and once as JSON for hydration. That is the intended design and it is not a bug. The bug is passing more than the component needs.

Warning

This is separate from your JavaScript bundle.

A bundle budget measures client chunks; the flight payload is inline HTML.

We had a bundle gate in CI the whole time and it never moved, because the dependency graph genuinely did not change.

Nothing we had was looking at this.

What We Actually Found

If you would rather do this step in the browser than by hand, SEO Checker handles it without a signup.

Measuring the payload by field name told the story immediately. On that 2.32 MB page, counting occurrences of each key inside the flight scripts:

  • description appeared 522 times
  • keywords appeared 294 times
  • faqs appeared 102 times
  • excerpt appeared 101 times
  • trending appeared 510 times
  • longDescription appeared 70 times

The component reads none of those. It renders a link, a label, and a small badge.

Every one of those fields was shipped to every visitor and every crawler because the page handed the component whole catalogue objects instead of the four fields it uses.

The sharpest single finding was an array we were passing purely so the component could render its length. The component never mapped it.

It called .length in four places to print a total. To print one number we were serializing 255 complete objects into the HTML.

Pro Tip

Before optimising anything, grep the component for what it actually reads off each object.

Ours came to slug, name, popular for a tool and slug, title, category for an article.

That list is the prop shape.

The Fix: Project Before the Client Boundary

Website Speed Test is the quickest way to apply what this section describes to your own file.

The fix is not clever. In the Server Component, map the data down to the fields the Client Component reads, and pass that. The array that only produced a count becomes a number.

  • Narrow each object to the fields the component reads, in the Server Component, before the boundary.
  • Replace any array you pass only to call .length on with the count itself.
  • Type the narrow shapes as Pick<Model, 'a' | 'b'> rather than hand writing them, so a field renamed upstream fails the build instead of silently arriving undefined.
  • Keep every element. Narrow the FIELDS, not the array, or you change counts the page displays.

That last point is worth stating because it is the easy way to break something while making a page smaller. Our component prints the number of articles from the array it receives and then slices it for display.

Trimming the array on the server would have silently changed a number a reader sees.

The Numbers

Worth keeping open alongside this guide: JSON Formatter, which covers the same job in a couple of clicks.

We measured the same page on one dev server, before and after, so the only variable was the projection. Comparing a production build against a dev build would have proved nothing.

  • Total HTML: 1,867,283 bytes down to 611,003 bytes, a 67.3% reduction.
  • Flight payload: 1,453,764 bytes down to 197,484 bytes, an 86.4% reduction.
  • Rendered markup: 410,348 bytes both times, byte for byte identical.
  • Links rendered: 255 tool links, 90 article links, 10 category links, identical.

The byte identical markup is the part that matters. It is the proof that the change moved weight and not output.

On the production build the effect was larger still: 2,318,515 bytes down to 556,662, a 76% reduction, with time to first byte going from 2.23 seconds to about 0.57 seconds across five samples.

Why We Cared: Crawl Budget, Not Lighthouse

The reason we went looking was not a performance score. It was a Search Console crawl stats export.

Over a 90 day window Google made 2,570 requests to the site, of which only 274 were HTML, against 406 URLs in the sitemap. That is fewer than 0.7 HTML fetches per URL per quarter.

Google's own guidance makes crawl rate a function of host response time. The page in question was linked from the footer of roughly 400 pages, so a crawler meets it constantly,

and it was both the heaviest URL on the site by a factor of three and the slowest we measured. Every second spent serving it is paid for out of some other page's chance of being fetched at all.

If your site is small and well linked, this argument does not apply to you and you can treat the payload as a pure performance question.

If you are struggling to get pages indexed, it is worth knowing that your heaviest URL is competing with your most important one.

How to Measure It Yourself

For the longer version of this point, read The Complete Guide to JSON Formatting and Validation for Developers.

The flight payload is every script tag of the form self.__next_f.push(...). Fetch a page, sum the byte length of those blocks, and compare against the total.

That gives you the share. To find out which fields are responsible, count occurrences of each key name inside those blocks.

A rough reading for a single page, in Node:

  • Fetch the page as plain text. Do not use a headless browser; you want the bytes on the wire.
  • Match /<script>self\.__next_f\.push\(([\s\S]*?)\)<\/script>/g and sum Buffer.byteLength of each full match.
  • Divide by Buffer.byteLength of the whole document to get the share.
  • For the field breakdown, count occurrences of each candidate key inside the joined matches.

Anything above about half the page is worth a look. Ours came in at 82.7%, while other pages on the same site measured 21% to 51% and turned out to be passing slim references already. A high share is a signal, not a verdict.

The Measuring Mistake That Nearly Ended This Investigation

JSON to Types & Code is the quickest way to apply what this section describes to your own file.

We made a related measurement in the same session and got it confidently wrong, and the mistake generalises, so it is worth spelling out.

React streaming SSR also parks page content inside a hidden container while the shell flushes. We wanted to know how much of a page sits inside one, so we stripped hidden containers with a regex and counted what was left.

The regex was the obvious one: match a div with a hidden attribute, then everything up to the next closing div tag, non greedy.

Warning

A non greedy match terminates at the FIRST nested closing tag, not the matching one.

On real markup it therefore strips almost nothing.

It told us 99.5% of the content was visible.

The true figure, measured with a depth counter, was 0.5%.

We had the answer exactly inverted and it looked entirely plausible.

HTML is not a regular language and nested elements of the same tag name are precisely where that bites. If you are measuring anything that depends on element nesting, walk the tags with a depth counter or use a real parser.

A regex will give you a number, and the number will be wrong in the reassuring direction.

What to Take Away

  • Every field you pass to a Client Component is serialized into the HTML, on top of being rendered. Pass the fields it reads and nothing else.
  • Never pass an array you only need a count from. Pass the count.
  • Your bundle budget cannot see any of this. The flight payload is inline HTML, not a chunk.
  • Measure before and after on the same server. A production versus dev comparison is not an isolation.
  • Byte identical rendered markup is the cleanest proof that a size change did not change behaviour.
  • If you strip nested elements with a regex, assume the result is wrong until a depth aware count agrees with it.

None of this required a rewrite. It was one projection in one Server Component, and the page still renders exactly the same markup it did before.

The easiest way to improve next.js rsc payload size is to follow a repeatable checklist, test the result, and use the right tool for the specific task instead of forcing one workflow on every use case.

For official background, standards, or platform guidance, review Next.js Server Components documentation.

Continue Reading on ToolsMonk

Explore related guides that build on this topic and help you go deeper into Next.js RSC Payload Size.

Useful External References

These authoritative resources add context, standards, or official guidance related to this topic.

Frequently Asked Questions

Common questions readers ask about this topic and the tools connected to it.

Often because Client Component props are serialized into the RSC flight payload, inline self.__next_f.push scripts, on top of the rendered markup. Every field of every object you pass is written verbatim, even fields the component never reads. On the article's sitemap page, 82.7 percent of a 2.32 MB response was that payload.

Project data down in the Server Component before it crosses the client boundary. Pass only the fields the Client Component reads, replace any array you only call length on with the count itself, and type the narrow shapes with Pick so renamed fields fail the build. Narrow the fields, not the array, or displayed counts change.

Fetch the page as plain text rather than through a headless browser, match every script of the form self.__next_f.push(...), sum their byte lengths, and divide by the size of the whole document. Counting each key name inside those blocks shows which fields are responsible. Above about half the page is worth investigating.

No. A bundle budget measures client chunks, while the flight payload is inline HTML, so the bundle gate stayed flat the whole time the page was oversized. Nothing in a typical build, typecheck or lint run looks at it either, which is why you have to measure the HTML directly.

On one dev server, total HTML fell from 1,867,283 to 611,003 bytes and the flight payload from 1,453,764 to 197,484 bytes, while the rendered markup stayed byte for byte identical. In production the page dropped 76 percent, from 2,318,515 to 556,662 bytes, and time to first byte fell from 2.23 to about 0.57 seconds.
TDD
ToolsMonk Developer Desk

Developer Desk · ToolsMonk

The Developer Desk is the engineering team that builds ToolsMonk's developer utilities, JSON, regex, encoding, hashing, formatters, and converters. These guides are written by the engineers who implement the tools, so the explanations of formats, algorithms, and edge cases come from building them, not just describing them. Every guide is researched, written, and reviewed by the same team that designs and maintains the underlying ToolsMonk tools, then fact-checked against primary sources and updated as standards change.

View all posts by ToolsMonk Developer Desk →

Related Articles

Enjoyed This Article?

Subscribe to get the latest ToolsMonk articles, tool updates, and productivity tips delivered to your inbox.

No spam, unsubscribe anytime. We respect your privacy.