From Blank Pages to Streamed HTML: How React Rendering Evolved

Client-side rendering (CSR) gave React developers a powerful model for building interactive interfaces, but it shipped a near-empty HTML document and forced users to wait for JavaScript to bootstrap the entire experience. Server-side rendering (SSR) fixed the blank page problem but introduced its own costs—heavier server load, slower Time to First Byte, and pages that stay unresponsive until hydration completes.

React Server Components (RSCs) represent the latest attempt to break this trade-off cycle. Instead of choosing one rendering strategy for the whole app, RSCs let developers decide per component whether it should render on the server or the client. That granular control can dramatically cut the JavaScript shipped to the browser while preserving interactivity where it matters.

To understand what RSCs actually change, it helps to trace how React got here in the first place.

The CSR Era and Its Costs

Early React applications ran entirely in the browser. Developers authored components as JavaScript classes, then bundled everything with tools like Webpack into optimized production code. The server responded with an HTML document containing only metadata in the <head> plus an empty <div> in the <body>—a hook where the app would eventually mount. All the JavaScript that built the user interface shipped separately and executed on the client.

That architecture had real advantages. Transitions between views were smooth because reactive components updated without triggering full page refreshes. Server load dropped since rendering happened on the user's device, and CDNs could serve the static JavaScript bundle from locations geographically close to the visitor.

But CSR also produced notorious pain points. Components fetching their own data independently often triggered waterfall network requests that serially delayed rendering—a pattern devastating enough that Eric Bailey's "Modern Health, frameworks, performance, and harm" documents real human harm from such performance failures. Early CSR pages were also largely invisible to search engine crawlers that never executed the JavaScript; that SEO issue has since been mitigated, but it was a significant business problem at the time.

Server-Side Rendering's Answer—and Its Limits

The response to CSR's blank screens was, somewhat ironically, to move rendering back to the server. With SSR, React rendered the initial HTML on the server and sent complete markup to the browser. Content displayed immediately without loading indicators, improving First Contentful Paint (FCP). Crawlers received fully-rendered pages they could index directly. Initial data fetching also moved server-side, closer to data sources, which—if structured properly—could eliminate fetch waterfalls.

SSR introduced a critical new step: hydration. After the browser received static HTML, React had to make it interactive by reconstructing its Virtual DOM on the client, based on the structure already in the actual DOM. This required shipping both React core and the application code to the browser. During hydration, React ran reconciliation, comparing the server-rendered DOM against its client-rendered equivalent. Mismatches triggered rehydration attempts and, if unresolvable, hydration errors.

The result was two distinct "flavors" of React: one server-side that knew how to generate static HTML from a component tree, and one client-side that knew how to make that HTML interactive. The two had to agree perfectly on the rendered output.

SSR came with fresh drawbacks. Servers now bore the rendering load that clients previously handled. While FCP improved, Time to First Byte (TTFB) regressed because the browser waited for the server to fetch data and generate HTML before sending anything back. TTFB isn't itself a Core Web Vital, but it drags down the metrics that are. Most critically, the entire page remained unresponsive until client-side hydration finished attaching event listeners—a delay that varies based on connection speed and device hardware.

Hybrid Interlude: SSG and ISR

Between SSR and RSCs, React developers gained two hybrid rendering strategies that tried to split the difference.

Static Site Generation (SSG) compiles the whole app at build time into static HTML and CSS files served from a CDN. This suits smaller projects with content that rarely changes—marketing sites, personal blogs—but not large e-commerce applications where content shifts with user interactions. SSG dramatically reduces server burden and improves TTFB-related metrics because no per-request rendering happens at all. The trade-off: any content change requires rebuilding the entire application.

Incremental Static Regeneration (ISR), created by the Next.js team, addresses that rebuilding problem. ISR generates an initial static version of each page at build time, then rebuilds individual pages when a user request reveals stale data. After regeneration, the server serves the updated pages as static content. That positions ISR neatly between SSG and full SSR. However, users can still encounter stale content while a page regenerates post-request. And unlike pure SSG, ISR requires a live server to perform those incremental rebuilds, ruling out pure CDN deployment.

Where React Server Components Fit

RSCs aim to resolve the trade-offs that CSR, SSR, SSG, and ISR each leave on the table. The core idea: rather than picking one rendering mode for an entire application, developers choose the right strategy for each component. A component that's purely presentational can render once on the server. A component that needs interactive state stays client-side. The flexibility reduces the JavaScript payload because only the components that genuinely need client interactivity are delivered as code to the browser.

Implementing RSCs in a framework like Next.js is straightforward enough that adding monitoring—useful for spotting performance issues in the new architecture—boils down to a single command in many cases.

How Server and Client Components Divide the Work

React Server Components introduce a clear architectural split: components are now categorized by where they execute, not by how they behave. Server Components run on the server and never ship their code to the browser—only the resulting HTML and props cross the wire. Client Components are the familiar components that run in the browser, handling user interactions and accessing browser APIs like localStorage.

Adoption of RSCs currently requires a framework. As of this writing, only three support them: Next.js, Gatsby, and RedwoodJS.

Wire diagram showing connected server components and client components represented as gray and blue dots, respectively.
Figure 3: Example of an architecture consisting of Server Components and Client Components. (Large preview)

Client Components

Client Components are labeled with a "use client" directive at the top of their files. In Next.js, every component is a Server Component by default, so this directive is what opts a component into client-side execution. The "use server" directive is separate—it marks Server Actions, which are RPC-like functions invoked from the client but executed on the server. It is not used to define Server Components.

Despite their name, Client Components aren't exclusively rendered in the browser. Next.js renders them on the server to produce initial HTML, allowing the browser to start painting immediately before hydration kicks in.

"use client"
export default function LikeButton() {
  const likePost = () => {
    // ...
  }
  return (
    <button onClick={likePost}>Like</button>
  )
}

Composition Rules Between the Two

There's an important constraint: Client Components can only explicitly import other Client Components. You cannot import a Server Component directly into a Client Component because of how re-rendering works. Client Components re-render frequently in response to state changes and interactions. If a Server Component lived in a Client Component's subtree, it would need to re-render along with it—but it has no presence on the client. The solution is to pass Server Components into Client Components via the children prop.

That said, importing a Server Component into a Client Component is technically possible, but it degrades the Server Component into a Client Component. If the component uses server-only APIs, this will throw an error. If it doesn't, its code will be bundled and sent to the browser. This nuance is easy to miss and critical to keep in mind during development.

Server Components: Why They Matter

Keeping code on the server yields several concrete advantages:

  • Large dependencies stay on the server. A component that relies on a hefty library can import it without inflating the browser bundle. The client receives only the rendered HTML, and the hydration step is skipped entirely.
  • Data access gets faster. Server Components sit close to databases and file systems, avoiding the network round trips that cause fetch waterfalls.
  • Sensitive data stays secure. API keys and personal tokens are processed in a controlled server environment rather than being exposed to the client.
  • Rendering can be cached. Results are reusable across requests and even across sessions, reducing render time and data transfer.

Server Components also enable HTML streaming. When the server encounters a slow component, it can pause rendering that subtree, emit a fallback, and stream in the real content later. Components wrapped in <Suspense> provide this fallback, and the framework swaps in the generated HTML when it's ready.

The Rendering Sequence

Next.js orchestrates a precise order of operations when a page is requested:

  1. The router matches the URL to a Server Component, builds the component tree, and directs React on the server to render it and all children.
  2. React generates an "RSC Payload" describing the page: what to expect, and what fallback to use for suspended regions.
  3. If a suspended component is encountered, React pauses that subtree and renders its fallback instead.
  4. Once all static components are processed, Next.js packages the rendered HTML with the RSC Payload and streams it to the client in chunks.
  5. Client-side React uses the RSC Payload to build the UI and hydrate each Client Component as it loads.
  6. As suspended Server Components finish rendering, their RSC Payloads stream in; if they contain Client Components, those are hydrated at that point as well.
Wire diagram of the RSC rendering lifecycle going from a blank page to a page shell to a complete page.
Figure 4: Diagram of the RSC Rendering Lifecycle. (Large preview)

Decoding the RSC Payload

At its core, the RSC Payload is a data format produced by the server as it renders the tree. It contains the rendered HTML, placeholders for Client Components, references to their JavaScript files (with invocation instructions), and any props a Server Component passes to a Client Component.

You don't need to interact with the payload directly, but recognizing it in network traffic is useful. In the browser's developer tools, under the Elements tab, the <script> tags near the bottom of the page contain individual RSC payloads—each line is one payload.

1:HL["/_next/static/media/c9a5bc6a7c948fb0-s.p.woff2","font",{"crossOrigin":"","type":"font/woff2"}]
2:HL["/_next/static/css/app/layout.css?v=1711137019097","style"]
0:"$L3"
4:HL["/_next/static/css/app/page.css?v=1711137019097","style"]
5:I["(app-pages-browser)/./node_modules/next/dist/client/components/app-router.js",["app-pages-internals","static/chunks/app-pages-internals.js"],""]
8:"$Sreact.suspense"
a:I["(app-pages-browser)/./node_modules/next/dist/client/components/layout-router.js",["app-pages-internals","static/chunks/app-pages-internals.js"],""]
b:I["(app-pages-browser)/./node_modules/next/dist/client/components/render-from-template-context.js",["app-pages-internals","static/chunks/app-pages-internals.js"],""]
d:I["(app-pages-browser)/./src/app/global-error.jsx",["app/global-error","static/chunks/app/global-error.js"],""]
f:I["(app-pages-browser)/./src/components/clearCart.js",["app/page","static/chunks/app/page.js"],"ClearCart"]
7:["$","main",null,{"className":"page_main__GlU4n","children":[["$","$Lf",null,{}],["$","$8",null,{"fallback":["$","p",null,{"children":"🌀 loading products..."}],"children":"$L10"}]]}]
c:[["$","meta","0",{"name":"viewport","content":"width=device-width, initial-scale=1"}]...
9:["$","p",null,{"children":["🛍️ ",3]}]
11:I["(app-pages-browser)/./src/components/addToCart.js",["app/page","static/chunks/app/page.js"],"AddToCart"]
10:["$","ul",null,{"children":[["$","li","1",{"children":["Gloves"," - $",20,["$...
self.__next_f.push([1,"PAYLOAD_STRING_HERE"]).

Each entry has a prefix that indicates its type:

  • HL payloads are "hints" that link external resources like stylesheets and fonts.
  • I payloads are "modules" that invoke scripts. This is how Client Components load. If a component is in the main bundle, it executes immediately. Lazy-loaded components receive a fetch script in the main bundle that retrieves their CSS and JavaScript only when rendering is needed; the server sends an I payload to trigger that fetcher at the right moment.
  • "$" payloads are DOM definitions for a Server Component, typically paired with streamed static HTML. This pattern appears when a suspended component finishes rendering: the server delivers both its HTML and RSC Payload together.

Understanding these markers makes it far easier to trace what the server is sending and when. The payload is small and deliberately structured—it's the coordination layer that lets the client assemble the final page in the correct order.

How RSC Streaming Works on the Wire

When a request hits a Next.js server, React renders every Server Component it can immediately. Suspended components get replaced by their fallback UI — usually the content of a <Suspense> boundary or a loading.js file. The server packages the resulting HTML and RSC payload into chunks and pushes them to the browser as they complete.

The response carries Transfer-Encoding: chunked, which tells the browser to accept partial HTML documents. You can verify this in DevTools by loading a page and inspecting the document request in the Network tab. The streaming behavior is also visible from the terminal:

curl -D - --raw localhost:3000 > chunked-response.txt
Headers and chunked HTML payloads.
Figure 6. (Large preview)

Each chunk is preceded by its byte size, and a zero-sized chunk marks the end of the stream. In the example above, the full page arrived in 16 separate chunks. The first chunk starts with the <!DOCTYPE html> declaration, and the closing </body> and </html> tags arrive only in the second-to-last chunk. The server streams the document top to bottom, pauses for suspended components, finishes them, and only then closes the document.

Browsers are lenient about incomplete documents, so they begin parsing and painting the partially streamed HTML immediately — without waiting for the closing tags.

Replacing Suspended Content

When an async Server Components finishes loading, React renders that subtree as HTML, builds its RSC payload, and hands both back to Next.js for streaming. The browser receives this chunk and needs a way to swap the fallback for the real content. Look closely at the HTML: fallback elements carry IDs like B:0 or B:1, while the streaming replacements arrive as templates with IDs like S:0 and S:1.

In the same chunk as the replacement content, the server ships an $RC function — React's completeBoundary — that locates the fallback node in the DOM and swaps it out for the streamed content.

Lazy-Loaded Client Components

If a suspended Server Component references a lazy Client Component, the server also sends an RSC payload chunk with fetch instructions for that component's code. The page load is never delayed by JavaScript that may not even be needed during the initial visit.

One caveat: lazy-loading a Client Component directly inside a Server Component doesn't work as you might expect. The reliable pattern is to put the dynamic call inside a wrapper Client Component. That wrapper's bundle becomes a small script that fetches the real Client Component's JavaScript and CSS on demand.

The Page Load Timeline

Tracing a page load in Chrome DevTools makes the whole flow visible. Capture a performance profile for a Next.js page and you'll see the request lifecycle in detail.

At the start, a "Parse HTML" span marks the arrival of the first streamed chunks. The initial HTML contains the page shell and link tags pointing at fonts, CSS, and JavaScript files, so the browser kicks off those fetches. The first frames paint shortly afterward, showing the shell with loading placeholders where suspended components will go. In a local development profile, that first meaningful paint often appears around 800 ms while the first HTML chunk arrived around 100 ms — because the server keeps streaming chunks throughout that interval. Production builds will be faster.

Later in the timeline, another "Parse HTML" span appears. That's a suspended Server Component finishing and its content arriving at the browser. If that component contains a lazy Client Component, the same span reveals a fetch for CSS and JavaScript files that were code-split out of the initial bundle. These files are only requested when actually needed — if the parent Server Component errors out, the Client Component's code is never fetched.

Near the end of the timeline, the DOMContentLoaded event fires right after the original localhost request finishes. That marks the server sending its final zero-sized chunk, closing the stream.

The Payoff in Numbers

In the captured profile, the main document request took five seconds total. A traditional SSR setup would keep the browser idle the whole time, waiting for a fully rendered page. A CSR approach would ship far more JavaScript and push work onto the client. With RSC streaming, the app was interactive within that same window — users could navigate and interact with Client Components in the main bundle long before the last chunk arrived.

Side-by-side reports from Sentry comparing an SSR version of the same app against the RSC variant make the advantage concrete. Streaming lets the page start pulling down resources while the document request is still in flight, which measurably improves Web Vitals.

Why RSC Architecture Wins

RSCs borrow the strengths of both server-side and client-side rendering and add streaming on top. That combination addresses the SEO and initial-load costs of CSR and lightens SSR workloads by keeping expensive rendering off the server's critical path.

The architecture splits components into two explicit roles: Server Components and Client Components. That split lets React and frameworks like Next.js deliver content progressively without sacrificing interactivity on the client.

Those benefits come with new complications. State management, authentication, and component architecture all need rethinking when parts of your tree never reach the client. These are real costs, and they'll drive new guidance as the ecosystem matures. But streaming alone — the ability to render the shell, replace suspended content on arrival, and delay Client Component code until it's required — already offers a meaningfully better user experience. The direction is clear: RSC architecture is becoming the default for modern React rendering.