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.
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.
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.
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 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.
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.
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.
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.
Get the latest tutorials, guides, and insights on AI, DevOps, Cloud, and Infrastructure delivered directly to your inbox.
Edge runtimes look like Node but aren't. Here's what actually breaks — CPU caps, no filesystem, no TCP sockets — and how we route around it.
How pods can talk to AWS, GCP, and Azure with no static keys — using audience-bound projected ServiceAccount tokens and the cluster OIDC issuer.
Explore more articles in this category
The edge is fast because it's constrained. This is the decision map for what belongs at the edge, what belongs at origin, and how compute, data, caching, and auth fit together.
Static keys leak. The question isn't if but how fast you notice and how clean your response runbook is when the pager goes off.
Edge code runs in hundreds of PoPs, lives for milliseconds, and gives you no shell. Here's how we get logs, traces, and metrics out of it anyway.
Evergreen posts worth revisiting.