# Streaming SSR at the Edge — RSC and HTML Streaming

URL: https://www.devopsness.com/blog/streaming-ssr-at-the-edge-rsc-and-html-streaming
Author: Kiril Urbonas
Published: 2026-07-09
Updated: 2026-08-30
Categories: Cloud
Tags: Serverless, Performance, Cdn, Backend, Edge

Summary: Buffered SSR makes users wait for the slowest query before they see anything. Streaming from the edge flips that — send the shell now, fill the gaps as data lands.

---
# Streaming SSR at the Edge — RSC and HTML Streaming

Buffered server rendering has one bad habit: it holds the whole response until every piece of data is ready. Your page has a header, a nav, some static copy, and one slow product query — and the user stares at a blank tab until that query returns. The browser can't paint anything, can't start fetching your CSS or fonts, because the first byte hasn't arrived.

Streaming fixes the timing, not the total work. You send the parts you already have immediately and flush the slow parts as they resolve. TTFB drops from "slowest query" to "time to render the shell," and the browser gets to work early — parsing markup, kicking off resource requests, painting the layout while your data is still in flight. Perceived speed improves even when the full page takes the same wall-clock time to finish.

## How the wire actually works

Underneath, this is chunked transfer encoding — the same HTTP/1.1 feature that's been around forever. The server declares no fixed `Content-Length` and writes the body in pieces. In modern runtimes you express that with a `ReadableStream` handed straight to a `Response`:

```js
export default function handler(request) {
  const stream = new ReadableStream({
    start(controller) {
      controller.enqueue(new TextEncoder().encode('<!doctype html><body>'));
      controller.enqueue(new TextEncoder().encode('<h1>Shell</h1>'));
      controller.close();
    },
  });

  return new Response(stream, {
    headers: { 'content-type': 'text/html; charset=utf-8' },
  });
}
```

That's the raw mechanism. You rarely write it by hand, because React does the enqueueing for you.

## renderToReadableStream and Suspense

React 18 and 19 render to a `ReadableStream` directly. That method is the Web Streams variant of the server renderer, and it's the one edge runtimes can run — no Node streams required.

```js
import { renderToReadableStream } from 'react-dom/server';

export default async function handler(request) {
  const stream = await renderToReadableStream(<App />, {
    bootstrapScripts: ['/main.js'],
  });

  return new Response(stream, {
    headers: { 'content-type': 'text/html; charset=utf-8' },
  });
}
```

The interesting part is what happens with `Suspense`. Any subtree wrapped in a boundary gets rendered as a fallback in the first flush, then React streams the real markup later with a tiny inline script that swaps the fallback for the resolved content:

```jsx
function Page() {
  return (
    <Layout>
      <Header />
      <Suspense fallback={<ProductSkeleton />}>
        <ProductDetails id={productId} />
      </Suspense>
    </Layout>
  );
}
```

`Header` and `Layout` ship right away. `ProductDetails` — which awaits your slow query — shows the skeleton first and gets patched in when the data lands. No client round-trip, no loading spinner state machine you wrote yourself. React Server Components extend the same idea past HTML: the RSC payload is itself a stream of serialized component output, so a Server Component that awaits data flushes its result into the stream as it resolves rather than blocking the render.

## The shell-then-fill pattern

The mental model worth keeping: your page has a **shell** (nav, layout, anything with no async data dependency) and **holes** (the Suspense boundaries). Ship the shell in the first chunk so the browser starts painting and loading assets. Let each hole fill on its own timeline. Put your boundaries where a slow data source lives, not around the whole page — one boundary wrapping everything gives you buffered rendering with extra steps.

## Running it at the edge

This pairs well with [edge functions](/blog/edge-computing-playbook-what-to-run-at-the-edge) because the Web Streams API is native to those runtimes. Next.js on Vercel's Edge Runtime and Cloudflare Workers both give you `ReadableStream`, `Response`, and `TextEncoder` out of the box — the exact primitives `renderToReadableStream` needs. Set a route to the edge runtime and the App Router streams by default:

```js
export const runtime = 'edge';
```

The shell now leaves a POP a few milliseconds from the user instead of a single origin region. That's most of the TTFB win.

## Gotchas that will bite you

**Buffering proxies.** A CDN or reverse proxy that buffers the whole response before forwarding silently converts your stream back into a blocking response. Nginx does this unless you set `proxy_buffering off` (or send `X-Accel-Buffering: no`). Test through your real edge path, not just localhost — the failure is invisible in markup and only shows up in timing.

**Headers are locked after the first byte.** Once the shell flushes, the status code and response headers are sent. You can't decide on a 500 or a redirect after that — if a Suspense boundary throws late, React streams an error to the client and swaps the fallback, but the response is already a 200. Do your auth checks and redirects before the first flush.

**Data still has to be near the edge.** Streaming hides latency; it doesn't delete it. If your Suspense boundary awaits a database 200ms away, that hole takes 200ms to fill no matter how fast the shell shipped. Keep the data the edge needs close — read replicas, edge KV, cached reads — or you've moved the render close and left the slow part far away.

**Mid-stream errors need boundaries.** An unhandled throw after the shell can't fail the whole request cleanly. Wrap risky subtrees in their own `Suspense` (and an error boundary) so one slow product query dying doesn't poison the rest of the page.

## The call we'd make

Turn on streaming for any route where the shell is cheap and one or two data reads are slow — product pages, dashboards, search results. Put Suspense boundaries around the slow reads specifically, keep that data reachable from the edge, and check `proxy_buffering` on every hop before you trust the numbers. Where a page is fully static or fully dynamic-fast, streaming buys little and adds moving parts. It's a latency tool for pages that are mostly ready and waiting on a little.

