skip to content

A Next.js App Router page passes a 5,000-row array from a Server Component into a client-rendered table, and the HTML document comes back around 2 MB — roughly twice the size of the JSON itself. Why does that data appear twice in the response, and what would you change?

level: seniorimportance: should knowfreq 42%

answer

  1. count the copies in one response
  2. markup for paint, props for hydration
  3. props cannot be recovered from markup
  4. narrow the projection before the crossing
  5. the document is on the critical path

basics

~20 s

The rows ship twice: once as the table markup produced by server-rendering the client component, and once as its serialized props inlined in the same document so hydration can rebuild the tree without refetching. Send fewer rows and fewer fields.

solid answer

~50 s

On the first request a Client Component is still rendered on the server, so the 5,000 rows appear as `<tr>` markup. But the browser also has to hydrate that component, and hydration needs the same props React had on the server — so Next inlines the RSC payload, props included, into the same document. Hence two copies, plus per-row HTML overhead. The fix is to reduce what crosses the boundary rather than to fight the duplication, which is inherent: narrow the projection so each row carries the four fields the table renders rather than the twenty the query returned, and paginate or window the rows so a page ships hundreds rather than thousands. Where the rows only need to be displayed, rendering them in a Server Component removes the props copy entirely, leaving just markup.

code

tsx · 21 lines
tsx
// app/orders/page.tsx  (Server Component)
import { OrdersTable } from './orders-table' // 'use client'

export default async function Page({
  searchParams,
}: {
  searchParams: Promise<{ page?: string }>
}) {
  const { page } = await searchParams
  const take = 50
  const skip = (Number(page ?? '1') - 1) * take

  // narrow fields AND slice rows before they cross the boundary
  const rows = await db.order.findMany({
    select: { id: true, customerName: true, total: true, status: true },
    take,
    skip,
  })

  return <OrdersTable rows={rows} />
}

go deeper

for a junior

Know that data passed into a client component is embedded in the page itself, so passing thousands of rows makes the page heavy even though it looks like an ordinary prop.

for a middle

Explain the two copies and why each exists — markup so the page paints, encoded props so hydration starts from the same inputs — and note that hydration cannot recover props from the HTML.

for a senior

Show the diagnosis: separate document size from script size before optimizing, confirm the mechanism, then reduce what crosses by projecting fields and paging rows rather than chasing compression.

for a principal

Own the standard for what may cross the boundary at scale — a size budget for serialized props, a review habit of questioning full datasets in props, and a clear position on when data belongs in the document versus behind a client fetch.

## Why there are two copies The duplication is not a bug or a misconfiguration; it falls out of what a first request has to accomplish. Two audiences need the same data in the same response. **The paint needs markup.** A `'use client'` component is not client-*only*; on the initial request it is server-rendered like any other component, so the browser gets real `<table>` markup and the user sees rows before any JavaScript executes. **Hydration needs the props.** When the client bundle runs, React re-creates that component in the browser and attaches it to the existing DOM. To produce the identical tree it needs the exact props the server used. It cannot recover them from the markup — the markup is the *output*, and the mapping is lossy and one-way. So Next inlines the RSC payload, containing the encoded props, into the same document as a series of scripts. So the response contains: the rows as HTML (with tag overhead per cell), plus the rows as encoded data, plus the framework and route payload around them. Two megabytes for a few hundred kilobytes of underlying JSON is entirely ordinary once tags and quoting are counted. A third copy is easy to forget: on a client-side navigation back to this route the router fetches the payload again as an RSC request and holds it in the client router cache. The cost is per navigation, not just once. ## Diagnosing it rather than guessing Separate the three sizes before touching anything, because teams routinely blame the JavaScript bundle for a document problem: - Document transfer size for the route (network panel, the document request). - JavaScript transfer size for the same route. - The size of the underlying data (`JSON.stringify(rows).length` on the server). If the document is large and the JS is normal, this is a payload problem and the bundle is a red herring. If the two copies are roughly symmetric — markup a bit larger, payload close to the raw JSON — you have confirmed the mechanism rather than assumed it. ## What actually reduces it The duplication factor is fixed at roughly two; the only lever is what crosses the boundary. **Narrow each row.** The most common finding is that the query returns everything and the table renders four columns. Project at the source: ```tsx const rows = await db.order.findMany({ select: { id: true, customerName: true, total: true, status: true }, }) return <OrdersTable rows={rows} /> ``` Trimming twenty fields to four cuts both copies proportionally, which is a far bigger win than any compression tweak. **Send fewer rows.** No human reads 5,000 rows. Paginate or window server-side and drive the page through the URL, so each response carries a slice. If the feature genuinely requires the full set — a client-side filter over everything — that is a deliberate decision to ship a dataset, and it should be argued for explicitly, with the document size as the stated cost. **Reconsider what has to be a client component at all.** If the rows are only displayed, rendering them in a Server Component removes the props copy entirely and leaves markup alone. Interactivity that operates on the *whole* set (sort, filter) is what usually forces the data across; pushing that operation to the server via the URL keeps the data on the server side of the boundary. **Fetch on the client instead.** Moving the query into the browser removes the data from the document altogether, at the cost of a request waterfall, a loading state, and having to authenticate the endpoint. It is the right call for a heavy, optional, below-the-fold view; it is the wrong call for the primary content of the page. ## The tradeoff to state out loud Every byte in the document is on the critical path for first paint and cannot be cached separately from the page; every byte moved to a client fetch is off the critical path but arrives later and needs its own endpoint and cache policy. Serializing into the payload buys a single round trip and no client-side loading state, and charges you document size for it. Interviewers are looking for that framing — that the duplication is structural, so the engineering happens in deciding what deserves to cross at all.

  • Why can't the framework just reconstruct the props from the server-rendered HTML and skip the payload copy?
    The markup is the output of the render, not its input, and the mapping is lossy — formatted text, dropped fields, conditionals and derived values cannot be reversed into the original props. Hydration must start from the same inputs the server had or the trees diverge. Reconstructing would be guesswork, so the props are transmitted explicitly.
  • Does gzip or brotli make the duplication a non-issue?
    It softens it — repeated tags and repeated field names compress well — but it does not remove it. The bytes still have to be decompressed and parsed, and the payload scripts still have to be executed to rebuild the tree, which is main-thread work proportional to the uncompressed size. Compression is a discount on transfer, not on parsing.
  • The product team insists on client-side sorting across the full dataset. How do you frame the decision?
    State the cost precisely: the full set crosses the boundary on every load, twice in the document, and again on each client navigation into the route. Then offer the alternative — sorting driven through the URL so the server returns the ordered slice — and let them choose between an instant sort with a heavy page and a fast page with a round trip per sort.
  • How do you tell this apart from a JavaScript bundle problem before you start optimizing?
    Compare the document transfer size against the script transfer size for the route in the network panel. A large document with normal scripts points at serialized props; large scripts with a small document points at what the client graph pulled in. They have different fixes, and teams often spend a day on the wrong one because they only looked at the total page weight.

saying these in an interview costs you the question

  • Blames the JavaScript bundle for a large HTML document
  • Thinks server-rendered HTML removes the need for the payload
  • Assumes compression makes duplicated data free
  • Believes hydration can rebuild props from the rendered markup
  • Suggests fixing it by disabling server rendering for the table

context