Streaming SSR with Suspense — Practical Patterns
TL;DR
Streaming SSR + Suspense lets you flush HTML to the browser before all data is ready. The shell + skeletons stream first (~50ms TTFB); each <Suspense> boundary streams its content as the data resolves. Best perceived load time for data-heavy pages. The senior knowledge: where to put boundaries, how data fetching interacts with streaming, error handling, and the proxy-buffering trap that breaks streaming silently.
In depth
How is streaming SSR different from regular SSR?
Regular SSR:
- Request hits server.
- Server renders full page (waits for all data).
- Server flushes complete HTML.
- Browser parses, paints, hydrates.
Streaming SSR:
- Request hits server.
- Server flushes the shell + Suspense fallbacks immediately.
- Server holds the connection open.
- As each suspended boundary’s data resolves, server flushes that section’s HTML + an inline script that swaps it in.
- Browser sees the shell render fast, then sections fill in.
The user sees pixels in milliseconds instead of seconds. TTFB drops; LCP can drop dramatically when LCP content is in the shell.
Where do you put Suspense boundaries?
Around any subtree that awaits data. Each boundary’s fallback is what the user sees while waiting.
export default function Dashboard() {
return (
<>
<Header /> {/* renders immediately */}
<Sidebar /> {/* renders immediately */}
<Suspense fallback={<StatsSkeleton />}>
<Stats /> {/* awaits stats data */}
</Suspense>
<Suspense fallback={<ActivitySkeleton />}>
<RecentActivity /> {/* awaits activity data */}
</Suspense>
<Suspense fallback={<NotifSkeleton />}>
<Notifications /> {/* awaits notification data */}
</Suspense>
</>
);
}Three boundaries = three independent streams. Stats arrives, Activity arrives, Notifications arrives — in whatever order. User sees content fill in.
Granularity — fine vs coarse boundaries?
Too coarse (one Suspense around the whole page):
- Equivalent to non-streaming SSR — wait for everything before any flush.
Too fine (every component is a Suspense):
- Many tiny chunks, more overhead, hard to design skeleton fallbacks for each.
Right granularity: one boundary per independent data unit (each widget that fetches its own data). Boundaries are also natural hydration boundaries — selective hydration works per boundary.
How does data fetching work inside streaming SSR?
In React Server Components, you await data directly:
async function Stats() {
// awaits, suspending the boundary
const data = await fetchStats();
return <StatsView data={data} />;
}In bare React with use(promise):
function Stats({ promise }: { promise: Promise<StatsData> }) {
const data = use(promise);
return <StatsView data={data} />;
}
<Suspense fallback={<Skeleton />}>
<Stats promise={fetchStats()} />
</Suspense>In TanStack Query with useSuspenseQuery:
function Stats() {
const { data } = useSuspenseQuery({ queryKey: ["stats"], queryFn: fetchStats });
return <StatsView data={data} />;
}All three: the component “throws a promise” when data isn’t ready; <Suspense> catches; renders fallback; re-renders when ready.
How is streaming HTML emitted? (the technical bit)
Each <Suspense> boundary is rendered initially with its fallback HTML and a placeholder ID:
<!-- initial shell, flushed immediately -->
<header>...</header>
<div id="suspense-1">
<div>Loading stats...</div>
</div>
<div id="suspense-2">
<div>Loading activity...</div>
</div>As each boundary’s data resolves, server appends:
<!-- streamed later -->
<template id="suspense-1-content">
<div>...rendered stats...</div>
</template>
<script>
// React's tiny streaming runtime swaps the template into #suspense-1
$RC("suspense-1", "suspense-1-content");
</script>The browser parses each chunk as it arrives, runs the inline script, swaps the content in. No JS framework needed for this — pure HTML + inline script does the streaming UX.
When hydration runs, React reconciles the now-complete tree.
Error handling in streaming.
A <Suspense> boundary that errors needs a sibling <ErrorBoundary> to catch:
<ErrorBoundary fallback={<ErrorView />}>
<Suspense fallback={<Skeleton />}>
<Stats /> {/* may throw */}
</Suspense>
</ErrorBoundary>If Stats rejects, the error boundary’s fallback streams to the client. Other boundaries are unaffected — partial failure handled gracefully.
Errors before the first flush behave differently: the whole response may revert to error rendering. Errors after the first flush stream as error fallbacks per boundary.
Proxy buffering — the silent killer.
Streaming SSR needs every layer between server and client to pass bytes through unbuffered.
Common culprits that buffer by default:
- nginx —
proxy_buffering on(default) → collects the whole response before forwarding. Setproxy_buffering off;for streaming routes. - CloudFlare (some configurations) — buffers small responses. Verify with
transfer-encoding: chunkedin the response. - Express middleware — compression middleware may buffer until a threshold. Use
compression({ threshold: 0 })or skip for streams. - CDN — CDNs may not honor streaming; verify your CDN’s docs.
Test in production: see if the user sees the shell within ~100ms of TTFB. If the whole page arrives at once, something’s buffering.
When does streaming SSR hurt?
- Pages where everything is below the fold and equal-priority — the user sees the same wait either way; streaming just complicates.
- Small / fast pages — TTFB is already 50ms; streaming overhead isn’t worth it.
- No CDN / direct server — streaming requires holding the connection; the server has to handle long-lived requests well (most modern stacks do).
The win is data-heavy, multi-section pages where some sections are slow — dashboards, feeds, multi-widget pages.
SEO + streaming?
Crawlers see the shell immediately. Most crawlers wait briefly for additional content; some don’t.
For SEO-critical content (article body, product details), put it outside suspended boundaries so it lands in the initial shell. For supporting widgets (related articles, ads, comments), suspend.
Streamed-in content reaches search crawlers eventually (Google waits for the full response), but the first-paint benefit is what users notice.
How does this interact with bot detection / user-agent sniffing?
Don’t UA-sniff to skip streaming for bots. Modern crawlers (Googlebot, Bingbot) handle streaming and Suspense fine. Old advice to “render static for bots” is outdated.
If you have specific edge-case crawlers that mishandle streaming, render an explicit static version for them, but plan for this to be a small minority of traffic.
Implementing streaming SSR without a framework.
React provides renderToPipeableStream (Node) and renderToReadableStream (Web):
// Express-style Node
import { renderToPipeableStream } from "react-dom/server";
app.get("/", (req, res) => {
const stream = renderToPipeableStream(<App />, {
bootstrapScripts: ["/main.js"],
onShellReady() {
res.setHeader("content-type", "text/html");
stream.pipe(res); // flush the shell
},
onError(error) {
console.error(error);
},
});
});Manual streaming SSR is doable for small apps; for production, frameworks (Next.js, Remix, Hono with React) handle the wire format, hydration, fallbacks, and error handling.
Gotchas / edge cases
Suspenseinside a transition — keeps showing the previous content during navigation (no flash to fallback). Powerful UX.use(promise)with new Promise each render — infinite suspend. Cache viauseMemo, parent prop, or library.- Server
console.login suspended boundary — fires when the boundary renders, which may be much later than the request started. Confusing for tracing. - Slow boundaries hold the connection — if a Suspense never resolves, the response never completes. Set timeouts in the data fetcher (
AbortSignal.timeout). - Hydration of streamed content — React handles per-boundary; selective hydration kicks in as each section arrives.
- Inline scripts (Content-Security-Policy) — React’s streaming runtime uses inline
<script>for the swap-in mechanism. Your CSP must allow it (with a nonce, ideally).
What a senior is expected to say 6
- “Streaming SSR with Suspense flushes the shell first, then each boundary as its data resolves. Best perceived LCP for data-heavy pages.”
- “Put boundaries around independent data units — too coarse defeats streaming; too fine adds overhead. One per widget is the sweet spot.”
- “Data fetching:
awaitin Server Components, oruse(promise)/useSuspenseQueryin client components. Either way, the boundary suspends until ready.” - “Pair with
<ErrorBoundary>for graceful partial failure — one slow section can fail without taking down the whole page.” - “Proxy buffering breaks streaming silently — nginx
proxy_buffering off, compression thresholds at 0, CDN must pass through chunked transfer.” - “Inline
<script>is used by React’s streaming runtime to swap fallbacks for content — CSP must allow it (nonce-based).”
Cross-references
- React Server Components: React Server Components — Deeper
- Suspense for data: Suspense for Data — vs Suspense for Code, Error Boundaries, Streaming SSR
- Hydration models: Hydration Models — Full, Selective, Progressive, Partial
- Next.js App Router: Next.js App Router Rendering Choices — Per-Route Control
- Core Web Vitals (TTFB, LCP): Core Web Vitals — LCP, INP, CLS
Further reading
- React docs —
renderToPipeableStream: https://react.dev/reference/react-dom/server/renderToPipeableStream - web.dev — Rendering on the web (streaming section): https://web.dev/articles/rendering-on-the-web
- Next.js — Streaming: https://nextjs.org/docs/app/building-your-application/routing/loading-ui-and-streaming