frontend / rendering modes / 04_streaming_ssr_with_suspense.md

Streaming SSR with Suspense — Practical Patterns

6 min read source

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:

  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.

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:

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

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

  • 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

  • “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