Web Engineering ·

Streaming HTML and Progressive Rendering: Chunked Transfer, React Suspense Boundaries, and Out-of-Order Streaming

How modern web frameworks stream HTML to browsers for faster perceived performance. Covers chunked transfer encoding, React 18+ Suspense streaming SSR, out-of-order streaming with placeholder slots, progressive hydration, RSC streaming vs traditional SSR, and practical implementation with Next.js and Hono.

Streaming HTML and Progressive Rendering: Chunked Transfer, React Suspense Boundaries, and Out-of-Order Streaming

Traditional SSR has a fixed shape: the server waits for all data, renders the full page, sends it in one response, the browser paints, React hydrates. Every step is a hard dependency on the previous one. The user sees nothing until the server finishes everything.

For pages where most of the content renders fast but one slow database query or third-party API call blocks the whole response, that constraint is genuinely expensive. You are making the user wait for the slowest thing before showing anything, including content that was ready in 50ms.

Streaming HTML breaks the hard ordering constraint. The server sends HTML in chunks as sections become ready, the browser renders what it has, and the rest arrives progressively. For the right page shapes, this changes perceived performance substantially without changing what the page eventually looks like.

This article covers the mechanics: chunked transfer encoding, how React 18 implements Suspense-based streaming SSR, out-of-order streaming with placeholder flushing, the relationship between streaming and progressive hydration, how RSC streaming differs from HTML streaming, and when to reach for each approach.

Chunked Transfer Encoding

The HTTP/1.1 protocol supports chunked transfer encoding via the Transfer-Encoding: chunked response header. Instead of the server declaring Content-Length upfront and sending the full body, it streams chunks with size prefixes:

HTTP/1.1 200 OK
Transfer-Encoding: chunked
Content-Type: text/html

1a
<html><head><title>Page</title>
1f
</head><body><header>Ready</header>
0

Each chunk is a hex byte count, a CRLF, the chunk data, and another CRLF. A zero-length chunk signals the end. Browsers have understood this since HTTP/1.1 and will progressively render HTML as chunks arrive. HTTP/2 and HTTP/3 use their own framing layers but the semantics are equivalent: the server can push frames without knowing the total body size in advance.

Node.js exposes this through the ServerResponse object. Calling res.write() without calling res.end() flushes a chunk. In web-standard environments (Cloudflare Workers, Deno, Bun), you use a ReadableStream with a TransformStream or a custom controller:

export async function handler(req: Request): Promise<Response> {
  const encoder = new TextEncoder();

  const stream = new ReadableStream({
    async start(controller) {
      // Shell: renders immediately
      controller.enqueue(
        encoder.encode(`<!DOCTYPE html><html><head><title>App</title></head><body>`)
      );
      controller.enqueue(
        encoder.encode(`<header>Navigation here</header><main id="content">`)
      );

      // Slow data fetch
      const data = await fetchSlowData();

      controller.enqueue(
        encoder.encode(`<section>${renderData(data)}</section>`)
      );
      controller.enqueue(encoder.encode(`</main></body></html>`));
      controller.close();
    },
  });

  return new Response(stream, {
    headers: { "Content-Type": "text/html; charset=utf-8" },
  });
}

The browser receives and paints the shell before fetchSlowData() resolves. This is the core primitive. Everything else React and Next.js do is built on top of this.

React 18 Suspense Streaming SSR

React 18 introduced renderToPipeableStream (Node.js) and renderToReadableStream (web streams) as replacements for renderToString. The difference is not just API shape: the new functions understand Suspense boundaries and can stream HTML with embedded deferred content.

When React encounters a Suspense boundary wrapping a component that throws a Promise (the mechanism behind use(), React.lazy(), and data fetching libraries), it does not wait for the Promise to resolve before flushing the boundary’s siblings. It emits a placeholder:

<!--$?--><template id="B:0"></template><!--/$-->

This is a comment-delimited template tag with a stable ID. The parent shell renders and flushes immediately. When the suspended component’s Promise resolves, React streams an additional HTML chunk that contains the actual content plus an inline script:

<div hidden id="S:0">
  <!-- actual rendered content for this boundary -->
  <article>...</article>
</div>
<script>
  $RC("B:0", "S:0");
</script>

$RC is a small function React injects that moves the content from the hidden div into the placeholder slot and replaces the template. This is out-of-order streaming: content for a boundary that resolves later can be delivered and inserted without blocking or reordering the HTML stream.

Here is the server entry point pattern:

import { renderToPipeableStream } from "react-dom/server";
import { IncomingMessage, ServerResponse } from "http";
import App from "./App";

export function handleRequest(req: IncomingMessage, res: ServerResponse): void {
  const { pipe, abort } = renderToPipeableStream(<App />, {
    bootstrapScripts: ["/client.js"],
    onShellReady() {
      // Shell is the content outside all Suspense boundaries.
      // This fires before any data fetching completes.
      res.setHeader("Content-Type", "text/html");
      res.setHeader("Transfer-Encoding", "chunked");
      pipe(res);
    },
    onShellError(error: unknown) {
      // Shell rendering failed. Send a fallback or error page.
      res.statusCode = 500;
      res.end("<html><body>Something went wrong</body></html>");
    },
    onError(error: unknown) {
      // An error in a non-shell boundary. React will render the error boundary fallback.
      console.error(error);
    },
  });

  // Timeout to prevent indefinite streaming
  setTimeout(abort, 10_000);
}

onShellReady fires as soon as React can flush the shell, before any suspended boundaries resolve. pipe(res) starts the stream. React then continues resolving boundaries in the background, writing chunks to res as each one finishes.

Suspense Boundaries in Practice

The placement of Suspense boundaries directly controls what the user sees and when. A single top-level boundary that wraps everything defeats the purpose: the shell would be empty and the stream would not start until everything resolved.

The productive pattern is wrapping the slow parts specifically:

// app/page.tsx (Next.js App Router)
import { Suspense } from "react";
import { ProductDetails } from "./ProductDetails";
import { RelatedProducts } from "./RelatedProducts";
import { ReviewSummary } from "./ReviewSummary";
import { ProductSkeleton, RelatedSkeleton, ReviewSkeleton } from "./Skeletons";

export default function ProductPage({ params }: { params: { id: string } }) {
  return (
    <div className="product-layout">
      <Suspense fallback={<ProductSkeleton />}>
        <ProductDetails id={params.id} />
      </Suspense>

      <aside>
        <Suspense fallback={<ReviewSkeleton />}>
          <ReviewSummary productId={params.id} />
        </Suspense>

        <Suspense fallback={<RelatedSkeleton />}>
          <RelatedProducts productId={params.id} />
        </Suspense>
      </aside>
    </div>
  );
}

Each boundary is independent. If ProductDetails resolves in 80ms, ReviewSummary in 300ms, and RelatedProducts in 600ms, the user sees each section as it resolves. With traditional SSR, everything waits for 600ms.

The fallback matters. A skeleton screen that matches the layout geometry of the actual content prevents layout shift when real content arrives. A generic spinner does not. Enforce this in your component library.

Out-of-Order Streaming and the Slot Mechanism

The $RC function React uses is deliberately minimal. When you look at what React actually emits into the stream, it is approximately:

<script>
  function $RC(a, b) {
    a = document.getElementById(a);
    b = document.getElementById(b);
    b.parentNode.removeChild(b);
    if (a) {
      a = a.previousSibling;
      var f = a.parentNode,
        c = a.nextSibling,
        e = 0;
      do {
        if (c && 8 === c.nodeType) {
          var d = c.data;
          if ("/$" === d)
            if (0 === e) break;
            else e--;
          else ("$" !== d && "$?" !== d && "$!" !== d) || e++;
        }
        d = c.nextSibling;
        f.removeChild(c);
        c = d;
      } while (c);
      for (; b.firstChild; ) f.insertBefore(b.firstChild, c);
      a.data = "$";
      a._reactRetry && a._reactRetry();
    }
  }
</script>

This DOM manipulation runs synchronously as each chunk arrives. The browser’s HTML parser processes the incoming stream, hits the script, executes it, and the placeholder template is replaced with real content. From the user’s perspective, sections of the page appear incrementally.

The implication for hydration is important: React’s client-side hydration needs to understand which Suspense boundary corresponds to which server-rendered HTML. The IDs (B:0, S:0) are deterministic based on the component tree order. React’s client runtime walks the DOM and attaches event handlers to the hydrated content as each boundary resolves. This is progressive hydration by construction: React does not need to hydrate the entire page before any part of it becomes interactive.

Progressive Hydration After Streaming

In React 18 with hydrateRoot, hydration is concurrent and interruptible. React will prioritize hydrating the component tree that the user is actively interacting with:

// client entry point
import { hydrateRoot } from "react-dom/client";
import App from "./App";

const root = hydrateRoot(document.getElementById("root")!, <App />);

If the user clicks a button in a section that has already streamed but is not yet hydrated, React will flush hydration for that subtree synchronously before processing the event. This is the “selective hydration” behavior: the client pays attention to user intent and prioritizes accordingly.

For React Server Components specifically, hydration is more nuanced. RSC payloads carry component trees, not just HTML. The client React runtime reconstructs the component tree from the RSC payload and reconciles it with the server-rendered HTML. Components marked "use client" get hydrated; server components do not, because they have no client runtime behavior. This reduces the JavaScript that needs to execute at hydration time.

RSC Streaming vs HTML Streaming

Traditional streaming SSR sends HTML. The browser renders it incrementally and React hydrates it. RSC streaming sends a different format: the React Server Component payload, which is a JSON-like binary format that describes the component tree, including the rendered output of server components and the boundaries for client components.

The wire format looks approximately like:

0:["$","div",null,{"children":["$","article",null,...]}]
1:"<html for boundary 1>"
2:["$","$L3",null,{}]
3:["$","button",null,{"onClick":"$4"}]

Each line is a module reference, a component tree chunk, or rendered HTML for a specific segment. The React client runtime parses this stream progressively, the same way the HTML streaming model works, but with richer semantics.

The practical difference for implementers: in RSC streaming, you can stream component trees and data together. A server component can fetch data and return JSX; the client receives both the data and the rendered output in a single stream. In traditional SSR streaming, you are streaming HTML and the client has no access to the underlying data unless you separately serialize it into the page.

In Next.js App Router, both happen: the server renders RSC payloads, those payloads include rendered HTML for the initial paint, and the client hydrates using the RSC payload to understand component boundaries. You do not typically interact with this layer directly; the framework handles it.

Implementation with Hono

For teams using Hono on the edge, streaming HTML is straightforward with streamSSE or a raw ReadableStream:

import { Hono } from "hono";
import { stream } from "hono/streaming";
import { renderToReadableStream } from "react-dom/server";
import App from "./App";

const app = new Hono();

app.get("/", async (c) => {
  const reactStream = await renderToReadableStream(<App />, {
    bootstrapScripts: ["/client.js"],
  });

  return new Response(reactStream, {
    headers: {
      "Content-Type": "text/html; charset=utf-8",
    },
  });
});

export default app;

renderToReadableStream returns a ReadableStream that you can pass directly to the Response constructor. Hono’s edge runtime (Cloudflare Workers, Deno Deploy) natively supports the web streams API so no adaptation layer is needed.

For more control, such as injecting a custom shell before React’s output, you can compose streams:

import { Hono } from "hono";
import { renderToReadableStream } from "react-dom/server";
import App from "./App";

const app = new Hono();

app.get("/", async (c) => {
  const encoder = new TextEncoder();

  const appStream = await renderToReadableStream(<App />, {
    bootstrapScripts: ["/client.js"],
  });

  // Wait for shell to be ready before starting
  await appStream.allReady; // or remove this to start streaming immediately

  const prefix = encoder.encode("<!DOCTYPE html>");

  const combined = new ReadableStream({
    async start(controller) {
      controller.enqueue(prefix);
      const reader = appStream.getReader();
      while (true) {
        const { done, value } = await reader.read();
        if (done) break;
        controller.enqueue(value);
      }
      controller.close();
    },
  });

  return new Response(combined, {
    headers: { "Content-Type": "text/html; charset=utf-8" },
  });
});

export default app;

Note: appStream.allReady waits for all Suspense boundaries to resolve before starting the stream. Remove that line if you want progressive streaming. Include it only for cases like PDF rendering or email where you need the complete document.

When Streaming Helps vs When It Hurts

Streaming is not free and it is not universally beneficial. The performance characteristics depend on the specific page shape.

ScenarioStreaming benefitNotes
Fast shell, slow data-dependent sectionsHighClassic use case. Shell renders at TTFB, sections fill in as data arrives.
All content ready in under 50msNear zeroThe overhead of streaming setup may exceed the benefit. A single flush wins.
CDN-cacheable pageNegativeStreamed responses typically cannot be edge-cached as a unit. Evaluate whether caching wins over streaming.
Single slow blocking queryMediumStreaming helps if you can restructure the page to show something before the query completes.
Multiple independent slow queriesHighEach boundary resolves independently. Parallelism compounds.
Critical above-the-fold content is slowLowStreaming the shell first helps only if the shell has visible content. An empty shell is useless.
Error boundaries wrapping uncertain third-party callsHighStreaming shows the page while the third-party call resolves. On failure, the error boundary catches without breaking the rest.
Pages with SEO as primary concernVariableGooglebot supports streaming and does process late-arriving content, but behavior is not as predictable as a single complete HTML response. Test your specific case.

The CDN caching tradeoff deserves more detail. Streamed responses with Transfer-Encoding: chunked and no Content-Length are typically not cached at the edge. If your page can be fully cached (logged-out marketing pages, documentation, static product listings), full-page caching will outperform streaming. Streaming is for dynamic, personalized pages that cannot be cached anyway.

A less obvious cost: streaming keeps connections open longer. If your Suspense boundaries take 5 seconds to resolve, each user holds a connection open for 5 seconds. At scale, this increases memory and file descriptor pressure.

Production Considerations

Set a stream timeout. A suspended boundary that never resolves (network failure, hung database query) will hold the connection open forever unless you abort:

const { pipe, abort } = renderToPipeableStream(<App />, {
  onShellReady() { pipe(res); },
  onError(err) { console.error(err); },
});

// Abort any incomplete stream after 8 seconds
const timeout = setTimeout(abort, 8_000);

res.on("finish", () => clearTimeout(timeout));

Measure TTFB and LCP separately. Streaming improves TTFB almost always. Whether it improves Largest Contentful Paint depends on whether your LCP element is in the shell or behind a Suspense boundary. Instrument both metrics. A case where TTFB improves but LCP is unchanged is a streaming implementation that is not moving the needle on user experience.

Watch for hydration mismatches. Streaming SSR generates IDs deterministically, but non-deterministic server-rendered content (timestamps, random keys, Math.random()) will produce hydration warnings in development and subtle bugs in production.

Suspense boundaries need real fallbacks. A null fallback causes a layout flash when real content arrives. A fallback that does not match the geometry of the real content causes layout shift, measurable in CLS. Design fallbacks with the same care as the real components.

Compression and chunking interact. When gzip or brotli compression is enabled on the server or proxy, small chunks may be buffered by the compression layer before being flushed. This negates streaming benefits for small initial shells. Configure your compression to flush on Suspense boundary resolution, or disable compression buffering for streaming responses. In Node.js with zlib, this means using Z_SYNC_FLUSH:

import zlib from "zlib";

const gzip = zlib.createGzip({ flush: zlib.constants.Z_SYNC_FLUSH });
reactStream.pipe(gzip).pipe(res);

The Actual Improvement

What streaming solves is the cascading wait: all-or-nothing SSR forces every user to wait for the slowest query even when only 10% of the visible page depends on that query. Progressive streaming lets the server parallelize rendering and delivery. The gains are about perceived latency, not raw throughput. Understand your page’s data dependency graph before committing to the streaming model, and instrument TTFB and LCP separately so you can confirm the improvement is real.

More in Web Engineering

How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement
Web Engineering ·

How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement

A deep dive into Rspack's Rust-based architecture, module graph construction, SWC transformation pipeline, webpack compatibility layer, incremental compilation, and what the tradeoffs look like for teams migrating from webpack.

How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js
Web Engineering ·

How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js

A deep dive into Turbopack's architecture covering the Turbo engine's incremental computation model, function-level caching, Rust-based module resolution, granular HMR invalidation, SWC integration, persistent caching, and how it compares to webpack, Vite, esbuild, and Rspack.

How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format
Web Engineering ·

How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format

A deep dive into gRPC internals: Protocol Buffer IDL and the buf codegen pipeline, HTTP/2 stream multiplexing and HPACK header compression, all four RPC patterns with TypeScript, channel management, deadline propagation, interceptors, load balancing from pick-first to xDS, the health checking protocol, and production tradeoffs vs REST and GraphQL.

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit
Web Engineering ·

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit

A deep dive into Node.js internals covering V8 JIT compilation, the six-phase libuv event loop, thread pool mechanics, microtask queue priority, async/await desugaring, CommonJS versus ESM module resolution, and production tuning considerations with TypeScript examples.