Frontend / Rendering modes / 04_streaming_ssr_with_suspense.md

Streaming SSR with Suspense — Practical Patterns

Updated 6 min read source
On this page6
  1. TL;DR
  2. In depth
  3. Gotchas / edge cases
  4. What a senior is expected to say
  5. Cross-references
  6. Further reading

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:

  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:

  • nginxproxy_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 / edge cases

  • 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).

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: await in Server Components, or use(promise)/useSuspenseQuery in 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

Further reading