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.
Interview Q&A
Q: How is streaming SSR different from regular SSR?
A:
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.
Q: Where do you put Suspense boundaries?
A: 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.
Q: Granularity — fine vs coarse boundaries?
A:
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.
Q: How does data fetching work inside streaming SSR?
A: In React Server Components, you await data directly:
async function Stats() {
const data = await fetchStats(); // awaits, suspending the boundary
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.
Q: How is streaming HTML emitted? (the technical bit)
A: 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.
Q: Error handling in streaming.
A: 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.
Q: Proxy buffering — the silent killer.
A: 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.
Q: When does streaming SSR hurt?
A:
- 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.
Q: SEO + streaming?
A: 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.
Q: How does this interact with bot detection / user-agent sniffing?
A: 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.
Q: Implementing streaming SSR without a framework.
A: 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
- “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: 03_rsc_deeper.md
- Suspense for data: ../05_react/suspense_for_data.md
- Hydration models: 02_hydration_models.md
- Next.js App Router: 05_nextjs_rendering_control.md
- Core Web Vitals (TTFB, LCP): ../15_performance/01_core_web_vitals.md
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