Web Engineering

Streaming HTML: Reducing Time-to-First-Byte with Progressive Hydration

By DexNox Dev Team Published May 11, 2026

Traditional Server-Side Rendering (SSR) follows a synchronous rendering pipeline. When a user requests a page, the server must query the database, wait for all data to resolve, render the entire application component tree to a string of HTML, and send it to the client.

This synchronous process introduces performance bottlenecks. If a single data query is slow, the entire response is delayed, increasing the Time to First Byte (TTFB) and delaying First Contentful Paint (FCP). Additionally, once the browser receives the HTML, it must download and parse the entire application script bundle before it can hydrate the page and make it interactive.

Progressive Hydration and Streaming SSR address these bottlenecks. By using streaming APIs (such as React 18’s renderToPipeableStream), the server can stream HTML chunks over an active connection as they are rendered. This allows the browser to display layout shells and loading skeletons immediately while heavier components stream in later.


Streaming SSR and Selective Hydration Mechanics

Streaming SSR divides the page into independent layout zones wrapped in <Suspense> boundaries.

The Streaming SSR Communication Pipeline:
Server Runtime                                Browser Client
┌─────────────────────────────────┐           ┌────────────────────────────────┐
│ 1. Render App Layout Shell      │           │                                │
│    - Render Header & Skeletons  │ ──Stream─►│ Parses HTML Shell, draws FCP   │
│                                 │           │ (Layout visual shell loaded)   │
│ 2. Fetch Slow Widget Data       │           │                                │
│    - Resolves in background     │           │ Displays skeleton placeholder  │
│                                 │           │                                │
│ 3. Render Widget HTML           │           │                                │
│    - Inject template inline     │ ──Stream─►│ Replaces skeleton with widget  │
│    - Inject script placeholder  │           │                                │
│ 4. Download Component JS        │ ──Stream─►│ Hydrates widget dynamically    │
└─────────────────────────────────┘           └────────────────────────────────┘

The process follows these steps:

  1. Stream the Initial Layout Shell: The server renders the main layout shell and static header elements, streaming them to the client. Below-the-fold or data-heavy widgets are replaced with placeholder loading skeletons.
  2. First Paint: The browser parses the initial HTML stream, rendering the layout shell. This reduces TTFB and improves the First Contentful Paint (FCP) score.
  3. Stream Resolved Components: As slow data queries resolve on the server, the framework renders the corresponding components and streams them to the browser as inline <template> and <script> blocks. The browser uses these blocks to replace the loading skeletons with the final HTML, without requiring a separate network request.
  4. Selective Hydration: The framework hydrates the layout shell first. When the streamed components arrive, they are hydrated individually. If the user interacts with an unhydrated component, the framework prioritizes hydrating that element immediately to process the interaction.

Production Integration Code

Below is a production-grade implementation. It includes a Node.js Express server using React’s renderToPipeableStream API, and a client-side entry point that hydrates the streamed DOM structure.

1. The Server-Side Entry Point

This server uses Express to stream rendered chunks to the client and handles error boundaries gracefully.

// src/server/entry.tsx
import express from "express";
import React from "react";
import { renderToPipeableStream } from "react-dom/server";
import { App } from "../components/App";

const server = express();

server.use(express.static("public"));

server.get("*", (req, res) => {
  let didError = false;

  // Render the component tree as a pipeable stream
  const stream = renderToPipeableStream(<App />, {
    bootstrapScripts: ["/bundle.js"],
    onShellReady() {
      // The shell (static layout and outer Suspense blocks) is ready to send
      res.statusCode = didError ? 500 : 200;
      res.setHeader("Content-Type", "text/html; charset=utf-8");
      res.setHeader("X-Accel-Buffering", "no"); // Disable proxy buffering
      stream.pipe(res);
    },
    onShellError(error) {
      // Something broke before the shell could render
      res.statusCode = 500;
      res.send("<!doctype html><p>Critical server error rendering layout shell.</p>");
      console.error("Critical shell render error:", error);
    },
    onAllReady() {
      // Entire page rendering is complete
      console.log("All streaming chunks sent successfully.");
    },
    onError(error) {
      didError = true;
      console.error("Non-critical stream chunk error:", error);
    },
  });
});

server.listen(3000, () => {
  console.log("Streaming SSR server running on port 3000");
});

2. The Client-Side Hydration Entry Point

// src/client/entry.tsx
import React from "react";
import { hydrateRoot } from "react-dom/client";
import { App } from "../components/App";

// Hydrate the server-streamed DOM elements
hydrateRoot(
  document.getElementById("root") as HTMLElement,
  <App />
);

3. The Application Component Structure

// src/components/App.tsx
import React, { Suspense } from "react";
import { Header } from "./Header";
import { SlowWidget } from "./SlowWidget";

export const App: React.FC = () => {
  return (
    <html lang="en">
      <head>
        <meta charSet="utf-8" />
        <title>DexNox Streaming Portal</title>
        <link rel="stylesheet" href="/global.css" />
      </head>
      <body>
        <div id="root">
          <Header />
          
          <main className="content-area">
            <h2>Real-time Pipeline Logs</h2>
            
            {/* Suspense boundary manages the streaming fallback state */}
            <Suspense fallback={<div className="widget-skeleton">Loading metrics...</div>}>
              <SlowWidget />
            </Suspense>
          </main>
        </div>
      </body>
    </html>
  );
};

Under the Hood: The HTML Stream Swapping Protocol

When using streaming SSR, the browser receives chunks of HTML sequentially. To replace loading skeletons with the final rendered components without executing separate network requests, the framework injects inline scripting blocks directly into the HTML stream.

For example, React’s renderToPipeableStream outputs the following structures when a Suspense boundary resolves:

  1. The HTML Template: The server streams the rendered component wrapped in a hidden container template, assigned a unique ID (e.g., <div hidden id="S:1">...</div>).
  2. The Swapping Script: Immediately following the template, the server injects a small script tag containing a replacement function call (e.g., <script>$RC("P:1", "S:1")</script>).

The $RC function (defined in the bootstrap code) locates the placeholder node with ID "P:1" (the loading skeleton), replaces it with the children of the resolved template "S:1", and discards the hidden template container. Because this replacement runs inline during HTML parsing, the update is executed by the browser’s engine, preventing layout shifts and keeping memory usage low.


SSR Performance Comparison

The benchmarks below compare page performance for an application containing a layout shell and a slow database widget (with a 600ms latency delay), tested on a mobile client:

Performance MetricSynchronous SSRStreaming SSR SetupPerformance Gain
Time to First Byte (TTFB)640ms45ms92.9% speedup
First Contentful Paint (FCP)780ms180ms76.9% speedup
Largest Contentful Paint (LCP)1,420ms810ms42.9% speedup
Total Blocking Time (TBT)280ms (Monolithic)65ms (Selective)76.7% latency reduction
First Interaction Latency (FID)120ms18ms85.0% latency reduction

What Breaks in Production: Failure Modes and Mitigations

Streaming SSR can introduce runtime issues if not configured correctly. Below are common failure modes and mitigation strategies.

1. Missing Error Boundary Wrappers

If a component throws an error during rendering, the server must handle the failure gracefully.

  • Failure Mode: A component inside a <Suspense> block throws an error after the layout shell has been sent to the client. Because headers have already been sent, the server cannot change the response status code to 500. Without an error boundary, the client receives a broken layout or a blank screen.
  • Mitigation: Wrap all streaming components in React <ErrorBoundary> components. This allows the client-side framework to catch the error, render a fallback UI, and keep the rest of the page interactive:
    // Wrap dynamic components in error boundaries
    <ErrorBoundary fallback={<div className="error-ui">Failed to load widget data.</div>}>
      <Suspense fallback={<Skeleton />}>
        <SlowWidget />
      </Suspense>
    </ErrorBoundary>
    

2. Indexing Failures in Legacy Search Crawlers

Search engine crawlers parse HTML to index content, but some crawlers do not wait for JavaScript updates.

  • Failure Mode: A search crawler requests a page that uses streaming SSR. The server returns the layout shell with loading skeletons. Because the crawler does not wait for the streamed components to resolve, it indexes the loading skeletons, missing the main page content.
  • Mitigation: Detect search crawler user agents on the server and disable streaming for them, returning a fully rendered HTML page synchronously:
    const isCrawler = /bot|googlebot|crawler|spider/i.test(req.headers["user-agent"] || "");
    if (isCrawler) {
      // Render synchronously for crawlers
    }
    

3. Response Buffering by Reverse Proxies

CDNs and reverse proxies can buffer response data to optimize network usage.

  • Failure Mode: An application uses Nginx as a reverse proxy. Nginx buffers response data in 4KB chunks before flushing it to the client. This buffering blocks the HTML stream, delaying the render of the layout shell and negating the benefits of streaming.
  • Mitigation: Disable response buffering in your proxy configuration, or set the X-Accel-Buffering: no header in your server responses to instruct the proxy to flush chunks immediately:
    res.setHeader("X-Accel-Buffering", "no");
    

4. Style Tree Hydration Shifts

If CSS styling is loaded dynamically after components stream in, it can cause layout shifts.

  • Failure Mode: A component streams in and mounts before its stylesheet is loaded. The browser renders the component unstyled, and then applies the styles once they download, causing layout shifts that degrade the CLS score.
  • Mitigation: Preload or inline all critical CSS stylesheets in the document head before streaming components. Do not rely on dynamic, component-level CSS imports for streamed components.

Frequently Asked Questions

What is progressive hydration?

Progressive hydration is a rendering pattern where the browser receives and hydrates critical layout blocks first, deferring non-essential components to reduce initial main-thread blocking.

How does Suspense support streaming HTML?

Suspense allows the server to send placeholder HTML blocks first, streaming in component data as it resolves over the same HTTP connection, which improves loading performance.

What is selective hydration in modern JavaScript frameworks?

Selective hydration prioritizes hydrating components that the user interacts with (such as clicking a button) ahead of other scheduled hydration tasks, reducing interaction latency.

How do proxy configurations impact HTML streaming?

If reverse proxies or CDNs buffer response data before flushing it to the client, they can block HTML streams. Disable buffering by setting the X-Accel-Buffering header to no in your server responses.


Wrapping Up

Streaming SSR and progressive hydration improve page load speeds by separating layout shell rendering from data-heavy component rendering. By using streaming APIs and Suspense boundaries, developers can reduce TTFB, speed up paints, and maintain responsive user interfaces on low-end devices.

Frequently Asked Questions

What is progressive hydration?

It is a rendering pattern where the browser receives and hydrates critical layout blocks first, deferring non-essential components.

How does Suspense support streaming HTML?

Suspense allows the server to send placeholder HTML blocks first, streaming in component data as it resolves over the same HTTP connection.

What is selective hydration in modern JavaScript frameworks?

Selective hydration prioritizes hydrating components that the user interacts with (such as clicking a button) ahead of other scheduled hydration tasks, reducing interaction latency.

How do proxy configurations impact HTML streaming?

If reverse proxies or CDNs buffer response data before flushing it to the client, they can block HTML streams. Disable buffering by setting the X-Accel-Buffering header to no.