Recommended resources
The freely available resources this lesson will direct you to.
- Next.js Streaming Tutorial — SSR, React Suspense & Loading Skeleton in Next.js 15www.youtube.com
- TanStack Start & Convex - Everything you need to know!www.youtube.com
- Enhancing SSR with React 18: A deep dive into server side ...www.equalexperts.com
- Advanced React SSR Techniques with Streaming and ...dev.to
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:
- Fetch Data: The server must wait for all data for the requested page to be fetched.
- Render HTML: React renders the entire component tree into a single HTML string (
renderToString). - Send Response: The complete HTML string is sent to the browser.
- 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:
- You wrap a slow component (one that fetches data) in a
<Suspense>boundary with afallbackprop. - 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. - Instead, it sends the
fallbackUI (e.g., a loading skeleton) as part of the initial HTML stream. - 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
loaderand 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 itsfallback, 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.Suspenseis the key: It allows you to define boundaries for streamable content by providing afallbackUI.useSuspenseQuerytriggers suspension: When rendered on the server, this hook causes the component to suspend if its data isn't ready, activating the nearestSuspensefallback.- Granular streaming offers the best UX: Wrapping individual, slow components in their own
Suspenseboundaries 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.