Streaming SSR at the Edge — RSC and HTML Streaming
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.
Key takeaways
- 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.
On this page
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:
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.
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:
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 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:
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.
Stay Updated
Get the latest tutorials, guides, and insights on AI, DevOps, Cloud, and Infrastructure delivered directly to your inbox.
GitHub Actions Best Practices in 2026: Workflows You Can Trust
A production-focused GitHub Actions guide: reusable workflows, least-privilege permissions, keyless OIDC to the cloud, SHA-pinned actions, environments with approvals, concurrency-safe deploys, and CodeQL/Dependabot gates — with copy-paste examples.
Kubernetes Workload Identity — Projected Tokens and OIDC to Cloud IAM
How pods can talk to AWS, GCP, and Azure with no static keys — using audience-bound projected ServiceAccount tokens and the cluster OIDC issuer.
More from Cloud
Explore more articles in this category
Best Serverless Databases in 2026 (Compared)
A practitioner comparison of the leading serverless databases by use case, cold-start behavior, branching, pricing model, and lock-in.
Cloudflare D1: The Edge SQLite Database Guide (2026)
A practitioner's look at Cloudflare D1, the serverless SQLite database built for Workers, covering setup, read replication, limits, and fit.
Neon vs PlanetScale: Serverless SQL Compared (2026)
A practitioner comparison of Neon's serverless Postgres against PlanetScale's Vitess-backed MySQL to help you pick the right database.
You might have missed
Evergreen posts worth revisiting.