Skip to main content

Someone wanted to learn this too, so Grasp built them a personal learning path.

You can keep exploring this one or create your own

Streaming SSR with React Suspense

Hello! Welcome to the next lesson in our module on Advanced Architecture and Optimization.

In our previous lesson, we focused on speeding up the server's work by executing independent data queries in parallel within our route loaders. This optimization is crucial for reducing the total time the server spends gathering data.

However, even with the fastest possible data fetching, traditional Server-Side Rendering (SSR) has a fundamental limitation: the browser receives nothing until the entire HTML page is generated on the server. This can still lead to a blank screen and a poor user experience for pages with slow data dependencies.

Today, we'll tackle this head-on. This lesson addresses the learning outcome: Implement streaming SSR with React Suspense to improve perceived performance. We will learn how to send the UI to the browser in chunks as it becomes ready, allowing the user to see and interact with the page much faster.

1. The "All-or-Nothing" Problem of Traditional SSR

In a traditional SSR setup, the server's process is strictly sequential and blocking:

  1. Fetch Data: The server must wait for all data for the requested page to be fetched.
  2. Render HTML: React renders the entire component tree into a single HTML string (renderToString).
  3. Send Response: The complete HTML string is sent to the browser.
  4. Hydrate: The browser loads all the necessary JavaScript and then makes the entire page interactive in one go.

This "all-or-nothing" approach means that one slow data fetch can hold the entire page hostage, delaying the Time to First Byte (TTFB) and First Contentful Paint (FCP).

To see a practical demonstration of this problem and its impact on user experience, let's watch the beginning of the following video.

Next.js Streaming Tutorial — SSR, React Suspense & Loading Skeleton in Next.js 15

This video from logicBase Labs clearly illustrates the user experience issues with traditional SSR, such as long initial load times and the 'false interaction' problem, where user input is lost during hydration.

Watch from 01:08 to 08:33. Pay close attention to the explanation of the sequential rendering process (fetch -> generate HTML -> send -> hydrate) and how it leads to a poor user experience.

As the video explains, this synchronous, blocking process is suboptimal. The following article reinforces these points and quantifies the performance impact.

Enhancing SSR with React 18: A deep dive into server side rendering optimisation

This article from Equal Experts provides a concise summary of the drawbacks of traditional SSR and introduces the key concepts of streaming and selective hydration that solve them.

Read the sections 'Is your SSR approach optimal?' and 'Metrics for demo application with Suspense and HTML streaming'. Notice the dramatic improvement in performance metrics like TTFB and FCP when moving from traditional SSR to a streaming approach.

2. The Solution: Streaming HTML with React Suspense

React 18 introduced a paradigm shift for SSR by enabling streaming HTML. Instead of waiting for the whole page, the server can send an initial HTML shell immediately. This shell includes the parts of the UI that are ready, along with placeholders for the parts that are still waiting on data.

The core technology that enables this is React.Suspense.

Here’s how it works on the server:

  1. You wrap a slow component (one that fetches data) in a <Suspense> boundary with a fallback prop.
  2. When React renders on the server, if it encounters a component inside <Suspense> that is not ready (i.e., it's suspended while waiting for data), React does not wait.
  3. Instead, it sends the fallback UI (e.g., a loading skeleton) as part of the initial HTML stream.
  4. Once the data for the suspended component is ready, React renders it on the server and streams the new HTML down to the client on the same request, along with a small inline script that swaps the fallback with the actual content.

This is made possible by a new server API, renderToPipeableStream, which frameworks like Tanstack Start use internally instead of the older, synchronous renderToString. You don't need to implement this low-level API yourself, but it's important to understand that it's the engine powering this feature.

Advanced React SSR Techniques with Streaming and ...

This article from dev.to explains the underlying mechanics, connecting the concepts of lazy loading, Suspense, and the switch to renderToPipeableStream.

Read the sections 'What is Streaming in SSR' and 'Lazy Loading and SSR'. This will clarify how streaming resolves the challenges of synchronous rendering by sending HTML in chunks and using Suspense fallbacks.

3. Implementing Streaming in a Tanstack Router App

Now, let's look at how to apply this in practice. The key is to strategically place <Suspense> boundaries around components that depend on asynchronous data.

How Suspense Works with Tanstack Query

A component "suspends" when it needs data that isn't available yet. With Tanstack Query, this is achieved by using useSuspenseQuery. When this hook is called inside a component during server rendering:

  • If the data has already been fetched by a loader and is in the cache, the component renders normally.
  • If the data is not in the cache, useSuspenseQuery "throws" a promise.
  • The nearest <Suspense> boundary above it in the component tree catches this promise, renders its fallback, and waits for the data-fetching promise to resolve before streaming the actual component's HTML.

Pattern 1: Page-Level Streaming

The simplest approach is to wrap a whole page or a large, data-dependent section in a single Suspense boundary. This is useful when the main content of a route is slow to load, but you want to show the surrounding layout (like a header and sidebar) immediately.

In a Tanstack Router app, you would typically wrap the <Outlet /> in your root route or another layout route.

Example: routes/__root.tsx

import { Outlet, createRootRoute } from '@tanstack/react-router';
import { Suspense } from 'react';
import { Header } from '../components/Header';
import { Sidebar } from '../components/Sidebar';
import { PageSkeleton } from '../components/PageSkeleton';

export const Route = createRootRoute({
  component: () => (
    <>
      <Header />
      <div className="main-layout">
        <Sidebar />
        <main>
          {/* 
            The Header and Sidebar render instantly.
            The content of the child route will suspend if it fetches data,
            showing PageSkeleton until it's ready.
          */}
          <Suspense fallback={<PageSkeleton />}>
            <Outlet />
          </Suspense>
        </main>
      </div>
    </>
  ),
});

This pattern is conceptually similar to the "automatic streaming" with loading.js shown in the Next.js video.

Pattern 2: Granular, Component-Level Streaming

For a more refined user experience, you can place multiple Suspense boundaries within a single page. This allows different parts of the page to load independently. A fast component can render immediately while a slower one shows a loading state, and they appear on the page as they become ready.

This is the most powerful use of streaming and directly improves perceived performance.

Next.js Streaming Tutorial — SSR, React Suspense & Loading Skeleton in Next.js 15

Let's return to the logicBase Labs video to see a demonstration of this granular streaming pattern. The presenter refactors the code to wrap individual cards in their own Suspense boundaries.

Watch from 16:21 to 23:33. This section is the core of the lesson. Observe how using Suspense for each item allows them to stream in concurrently, rather than waiting for all of them to be ready.

Here's how that pattern might look in a Tanstack project. Imagine a dashboard page with several independent widgets.

Example: routes/dashboard.tsx

import { createFileRoute } from '@tanstack/react-router';
import { Suspense } from 'react';
import { UserProfileWidget, UserProfileSkeleton } from '../components/UserProfileWidget';
import { RecentActivityWidget, ActivitySkeleton } from '../components/RecentActivityWidget';
import { StatsWidget, StatsSkeleton } from '../components/StatsWidget';

// Assume each widget component uses `useSuspenseQuery` internally to fetch its own data.

function DashboardPage() {
  return (
    <div>
      <h1>Dashboard</h1>
      <div className="widget-grid">
        <Suspense fallback={<UserProfileSkeleton />}>
          <UserProfileWidget />
        </Suspense>
        
        <Suspense fallback={<ActivitySkeleton />}>
          <RecentActivityWidget />
        </Suspense>
        
        <Suspense fallback={<StatsSkeleton />}>
          {/* This widget might be slower than the others */}
          <StatsWidget />
        </Suspense>
      </div>
    </div>
  );
}

export const Route = createFileRoute('/dashboard')({
  component: DashboardPage,
});

In this example, the UserProfileWidget and RecentActivityWidget might appear almost instantly, while the StatsWidget streams in a moment later, without blocking the rest of the page from rendering.

4. Ensuring Dynamic Rendering for Streaming

One final, crucial point. Streaming is only effective for pages that are dynamically rendered on the server at request time. If a page is statically generated at build time (SSG), its content is already fully rendered, and there's nothing to stream.

In frameworks like Next.js, you might use export const dynamic = 'force-dynamic'. In Tanstack Start, the distinction between SSR and SSG is typically handled by your build configuration and whether your loaders have dependencies that can only be resolved at request time (like headers or cookies). When implementing streaming, always ensure the target route is configured for dynamic SSR to see the benefits.

Conclusion

You have now learned how to break free from the limitations of traditional SSR. By strategically using React.Suspense, you can implement streaming to deliver a faster, more responsive user experience.

Key Takeaways:

  • Traditional SSR is blocking: It waits for all data before sending any HTML, increasing TTFB and FCP.
  • Streaming SSR is non-blocking: It sends an initial HTML shell immediately and streams in slower content as it becomes ready on the server.
  • React.Suspense is the key: It allows you to define boundaries for streamable content by providing a fallback UI.
  • useSuspenseQuery triggers suspension: When rendered on the server, this hook causes the component to suspend if its data isn't ready, activating the nearest Suspense fallback.
  • Granular streaming offers the best UX: Wrapping individual, slow components in their own Suspense boundaries allows the page to populate progressively.

Next Up:

We have optimized how we fetch data (parallel queries) and how we deliver the initial page (streaming). The next logical step in performance optimization is to avoid doing this work altogether when possible. In our next lesson, we will explore how to configure HTTP caching headers (e.g., Cache-Control) for server-rendered pages, instructing browsers and CDNs to cache the results of our hard work.

Can't find a good explanation? Sign up and we'll make it for you

Sign up