Frontend / Rendering modes / 04_streaming_ssr_with_suspense.md

Streaming SSR with Suspense — Practical Patterns

Updated 5 interview angles 7 min read source
On this page15
  1. How is streaming SSR different from regular SSR?
  2. Where do you put Suspense boundaries?
  3. Granularity — fine vs coarse boundaries?
  4. How does data fetching work inside streaming SSR?
  5. How is streaming HTML emitted? (the technical bit)
  6. Error handling in streaming.
  7. Proxy buffering — the silent killer.
  8. When does streaming SSR hurt?
  9. SEO + streaming?
  10. How does this interact with bot detection / user-agent sniffing?
  11. Implementing streaming SSR without a framework.
  12. Gotchas
  13. Related
  14. Further reading
  15. Interview angle

Streaming SSR with Suspense — Practical Patterns

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.

How is streaming SSR different from regular SSR?

Regular SSR:

  1. Request hits server.
  2. Server renders full page (waits for all data).
  3. Server flushes complete HTML.
  4. Browser parses, paints, hydrates.

Streaming SSR:

  1. Request hits server.
  2. Server flushes the shell + Suspense fallbacks immediately.
  3. Server holds the connection open.
  4. As each suspended boundary’s data resolves, server flushes that section’s HTML + an inline script that swaps it in.
  5. 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.

tsx
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:

tsx
async function Stats() {
  // awaits, suspending the boundary
  const data = await fetchStats();
  return <StatsView data={data} />;
}

In bare React with use(promise):

tsx
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:

tsx
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:

html
<!-- 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:

html
<!-- 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:

tsx
<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. Set proxy_buffering off; for streaming routes.
  • CloudFlare (some configurations) — buffers small responses. Verify with transfer-encoding: chunked in 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):

ts
// 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

  • Suspense inside 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 via useMemo, parent prop, or library.
  • Server console.log in 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).

Further reading

Interview angle 5

  • “How does streaming SSR work?” - the server flushes the shell immediately, then streams each Suspense boundary’s HTML as its data resolves. The user sees layout and static content while the slow query is still running, so perceived LCP stops tracking the slowest fetch.
  • “Where do you put the boundaries?” - around independent units of data, one per widget as a rule of thumb. Too coarse and you are back to waiting for the slowest thing in the group; too fine and you pay per-boundary overhead and the page visibly assembles itself in pieces.
  • “How does the data get fetched?” - await directly in a Server Component, or use(promise) / useSuspenseQuery in a client one. Either way the boundary suspends until the data is ready and the framework streams the result.
  • “What happens if one section fails?” - with an ErrorBoundary inside the Suspense, that section renders its error state and the rest of the page is unaffected. Without one, a single failed fetch takes down the whole tree, which defeats the point of splitting it.
  • “Streaming works locally and not in production. Where do you look?” - the infrastructure between you and the user. proxy_buffering off in nginx, compression that buffers until a size threshold, and a CDN that must pass chunked transfer through. It fails silently - the page still works, it just arrives all at once.
    • Follow-up: “Any CSP interaction?” - yes. React’s streaming runtime injects small inline scripts to swap each fallback for its real content, so a strict CSP must allow them via a nonce or the boundaries never resolve.