Back to journal
Engineering

A Practical Guide to React Suspense and Streaming SSR

Suspense isn't just for loading spinners. It fundamentally changes how you architect data fetching — streaming HTML to the browser before your slowest query finishes.

PMML Engineering 1 May 2026 10 min read 0 views
A Practical Guide to React Suspense and Streaming SSR

React Suspense has been "coming soon" for years. It's here now — and it changes everything about how you think about loading states.

This isn't a theory post. We'll build a real page with streaming SSR, progressive loading, and nested Suspense boundaries — the patterns Next.js App Router is built around.

The Old Way vs. The New Way

React component tree with suspense boundaries

Old: Waterfall Loading

// Page loads → fetch all data → render entire page
export default function Dashboard() {
  const [user, setUser] = useState(null);
  const [posts, setPosts] = useState(null);
  const [analytics, setAnalytics] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    Promise.all([fetchUser(), fetchPosts(), fetchAnalytics()])
      .then(([u, p, a]) => {
        setUser(u);
        setPosts(p);
        setAnalytics(a);
      })
      .finally(() => setLoading(false));
  }, []);

  if (loading) return <Spinner />;
  // Entire page blocked until ALL data loads
  return <>{/* ... */}</>;
}

Problems:

  1. Blank screen until every API call finishes
  2. Slowest endpoint dictates total load time
  3. If analytics takes 3 seconds, user sees nothing for 3 seconds

New: Streaming with Suspense

// Each section loads independently — fast parts show immediately
export default function Dashboard() {
  return (
    <div>
      <Suspense fallback={<UserSkeleton />}>
        <UserProfile />
      </Suspense>

      <Suspense fallback={<PostsSkeleton />}>
        <RecentPosts />
      </Suspense>

      <Suspense fallback={<AnalyticsSkeleton />}>
        <Analytics />
      </Suspense>
    </div>
  );
}

Now each section loads independently. User info appears in 100ms. Posts in 200ms. Analytics in 3 seconds — but the user has been interacting with the page for 2.9 seconds already.

Part 1: Async Server Components

In Next.js App Router, server components can be async:

// This runs on the server — no useEffect, no useState
async function UserProfile() {
  const user = await db.query("SELECT * FROM users WHERE id = $1", [userId]);

  return (
    <div className="flex items-center gap-4">
      <img src={user.avatar} alt={user.name} className="h-12 w-12 rounded-full" />
      <div>
        <h2 className="font-bold">{user.name}</h2>
        <p className="text-sm text-gray-500">{user.email}</p>
      </div>
    </div>
  );
}

No loading state management. No useEffect. The component is the data fetch. Suspense handles the loading boundary.

Part 2: Nested Suspense Boundaries

Progressive loading states in a modern UI

The power is in nesting — each boundary is independent:

// app/dashboard/page.tsx
import { Suspense } from "react";

export default function DashboardPage() {
  return (
    <main className="grid grid-cols-3 gap-6 p-8">
      {/* Left column — loads fast */}
      <div className="col-span-2 space-y-6">
        <Suspense fallback={<div className="h-24 animate-pulse rounded-xl bg-gray-800" />}>
          <WelcomeBanner />
        </Suspense>

        <Suspense fallback={<div className="h-64 animate-pulse rounded-xl bg-gray-800" />}>
          <RecentActivity />
        </Suspense>
      </div>

      {/* Right column — might be slow */}
      <div className="space-y-6">
        <Suspense fallback={<div className="h-40 animate-pulse rounded-xl bg-gray-800" />}>
          <QuickStats />
        </Suspense>

        <Suspense fallback={<div className="h-96 animate-pulse rounded-xl bg-gray-800" />}>
          <AnalyticsChart />
        </Suspense>
      </div>
    </main>
  );
}

Each Suspense boundary:

  1. Renders its fallback immediately (skeleton/shimmer)
  2. Streams the real content as soon as the async component resolves
  3. Replaces the fallback with the real content — no layout shift if sized correctly

Part 3: The loading.tsx Convention

Next.js App Router auto-wraps your page in Suspense:

app/
  dashboard/
    page.tsx        ← your async page
    loading.tsx     ← automatic Suspense fallback
// app/dashboard/loading.tsx
export default function DashboardLoading() {
  return (
    <main className="grid grid-cols-3 gap-6 p-8">
      <div className="col-span-2 space-y-6">
        <div className="h-24 animate-pulse rounded-xl bg-gray-800" />
        <div className="h-64 animate-pulse rounded-xl bg-gray-800" />
      </div>
      <div className="space-y-6">
        <div className="h-40 animate-pulse rounded-xl bg-gray-800" />
        <div className="h-96 animate-pulse rounded-xl bg-gray-800" />
      </div>
    </main>
  );
}

This gives instant page transitions — the loading skeleton shows immediately while the page data streams in.

Part 4: Streaming SSR in Action

Streaming SSR boosting page load performance

Here's what happens on the wire:

Browser request → Server starts rendering

Time 0ms:   Server sends <html>, <head>, navigation, skeleton shells
Time 100ms: User data resolves → server streams UserProfile HTML chunk
Time 200ms: Posts resolve → server streams RecentActivity HTML chunk
Time 3000ms: Analytics resolves → server streams AnalyticsChart HTML chunk

Browser progressively hydrates each chunk as it arrives

The browser doesn't wait. It renders whatever HTML has arrived and keeps listening for more. This is streaming — the response is a continuous flow, not a single payload.

Part 5: Error Boundaries with Suspense

Pair Suspense with Error Boundaries for complete resilience:

"use client";

import { Component, type ReactNode } from "react";

type Props = {
  children: ReactNode;
  fallback: ReactNode;
};

type State = { hasError: boolean; error: Error | null };

class ErrorBoundary extends Component<Props, State> {
  state: State = { hasError: false, error: null };

  static getDerivedStateFromError(error: Error) {
    return { hasError: true, error };
  }

  render() {
    if (this.state.hasError) {
      return this.props.fallback;
    }
    return this.props.children;
  }
}

// Usage:
function DashboardPage() {
  return (
    <ErrorBoundary fallback={<p>Analytics unavailable</p>}>
      <Suspense fallback={<AnalyticsSkeleton />}>
        <Analytics />
      </Suspense>
    </ErrorBoundary>
  );
}

If the analytics API is down, only that section shows an error. The rest of the dashboard works fine.

Exercises

Exercise 1: Staggered Loading Animation

Create a Suspense fallback that animates in sequence:

function StaggeredSkeleton({ count }: { count: number }) {
  return (
    <div className="space-y-3">
      {Array.from({ length: count }, (_, i) => (
        <div
          key={i}
          className="h-16 animate-pulse rounded-xl bg-gray-800"
          style={{ animationDelay: `${i * 100}ms` }}
        />
      ))}
    </div>
  );
}

Exercise 2: Prefetching with use()

React 19's use() hook lets client components consume promises:

"use client";
import { use, Suspense } from "react";

function Comments({ commentsPromise }: { commentsPromise: Promise<Comment[]> }) {
  const comments = use(commentsPromise);

  return (
    <ul>
      {comments.map((c) => (
        <li key={c.id}>{c.text}</li>
      ))}
    </ul>
  );
}

// Parent starts the fetch, child consumes it:
function PostPage({ post }: { post: Post }) {
  const commentsPromise = fetchComments(post.id);

  return (
    <article>
      <h1>{post.title}</h1>
      <Suspense fallback={<p>Loading comments...</p>}>
        <Comments commentsPromise={commentsPromise} />
      </Suspense>
    </article>
  );
}

Exercise 3: Suspense-Aware Skeleton Components

Build a reusable skeleton system:

function Skeleton({ className }: { className?: string }) {
  return (
    <div
      className={`animate-pulse rounded-lg bg-gray-800 ${className ?? ""}`}
    />
  );
}

function CardSkeleton() {
  return (
    <div className="rounded-2xl border border-gray-800 p-6 space-y-4">
      <Skeleton className="h-4 w-1/3" />
      <Skeleton className="h-8 w-2/3" />
      <Skeleton className="h-4 w-full" />
      <Skeleton className="h-4 w-4/5" />
    </div>
  );
}

Mental Model

Think of Suspense as progressive enhancement for data:

  • Without Suspense: Everything loads or nothing loads
  • With Suspense: Fast content appears immediately, slow content streams in

The server doesn't wait for your slowest query. It sends what it has and keeps the connection open for the rest. That's streaming SSR.

What's the slowest page in your app? Suspense could make it feel instant. Tell us at pmml.info@gmail.com.

PMML Engineering builds performant web applications, AI products, and real-time systems for teams that ship. Based in Accra, Ghana.

#react#suspense#streaming-ssr#next-js#performance

Keep reading

You might also like