Back to Blog

React Suspense Patterns: Boundaries, Error Recovery, Streaming SSR, and Data Fetching

Master React Suspense in production: compose Suspense and error boundaries, stream HTML with React 18 streaming SSR, integrate with React Query and Next.js App Router, and avoid common pitfalls like waterfall fetching and missing error boundaries.

Viprasol Tech Team
October 14, 2026
13 min read

React Suspense lets components declare that they're "waiting" for something โ€” data, code, or an image โ€” and the nearest <Suspense> boundary renders a fallback until they're ready. The result: loading states become declarative, collocated with the component that needs the data, rather than scattered if (loading) return <Spinner /> checks.

In Next.js App Router, Suspense also enables streaming: the server sends HTML in chunks as each Suspense boundary resolves, so users see content progressively rather than waiting for the slowest data fetch.


Suspense Boundary Placement

The placement of Suspense boundaries determines the granularity of loading states:

// src/app/dashboard/page.tsx

// Option A: One boundary wraps everything
// โ†’ Entire page shows spinner until ALL data loads (bad for perceived performance)
export default function DashboardPage() {
  return (
    <Suspense fallback={<PageSpinner />}>
      <StatsSection />        {/* Fast: 50ms */}
      <ProjectsTable />       {/* Slow: 800ms */}
      <ActivityFeed />        {/* Medium: 300ms */}
    </Suspense>
  );
}

// Option B: Granular boundaries per section (recommended)
// โ†’ Each section shows content as soon as its data is ready
export default function DashboardPage() {
  return (
    <div className="grid grid-cols-12 gap-6">
      {/* Fast content shows immediately */}
      <Suspense fallback={<StatsSkeleton />}>
        <StatsSection />
      </Suspense>

      {/* Slow content doesn't block fast content */}
      <Suspense fallback={<TableSkeleton rows={10} />}>
        <ProjectsTable />
      </Suspense>

      <Suspense fallback={<FeedSkeleton />}>
        <ActivityFeed />
      </Suspense>
    </div>
  );
}

Error Boundaries with Suspense

Every Suspense boundary should have a corresponding error boundary. Without it, thrown errors bubble to the nearest React error boundary โ€” which may be at the root, crashing the whole page:

// src/components/AsyncBoundary.tsx
"use client";

import { Component, ErrorInfo, ReactNode, Suspense } from "react";

interface ErrorBoundaryState {
  hasError: boolean;
  error: Error | null;
}

// Class component โ€” error boundaries must be class components
class ErrorBoundary extends Component<
  {
    fallback: ReactNode | ((error: Error, reset: () => void) => ReactNode);
    children: ReactNode;
  },
  ErrorBoundaryState
> {
  state: ErrorBoundaryState = { hasError: false, error: null };

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

  componentDidCatch(error: Error, info: ErrorInfo) {
    // Log to error tracking (Sentry, etc.)
    console.error("AsyncBoundary caught error:", error, info.componentStack);
  }

  reset = () => {
    this.setState({ hasError: false, error: null });
  };

  render() {
    if (this.state.hasError && this.state.error) {
      const { fallback } = this.props;
      return typeof fallback === "function"
        ? fallback(this.state.error, this.reset)
        : fallback;
    }
    return this.props.children;
  }
}

// Combined Suspense + Error boundary โ€” the pattern you'll use most
interface AsyncBoundaryProps {
  loadingFallback: ReactNode;
  errorFallback?: ReactNode | ((error: Error, reset: () => void) => ReactNode);
  children: ReactNode;
}

export function AsyncBoundary({
  loadingFallback,
  errorFallback,
  children,
}: AsyncBoundaryProps) {
  const defaultErrorFallback = (error: Error, reset: () => void) => (
    <div className="flex flex-col items-center gap-2 p-4 text-center">
      <p className="text-sm text-red-600">{error.message}</p>
      <button
        onClick={reset}
        className="text-sm text-blue-600 underline hover:no-underline"
      >
        Try again
      </button>
    </div>
  );

  return (
    <ErrorBoundary fallback={errorFallback ?? defaultErrorFallback}>
      <Suspense fallback={loadingFallback}>{children}</Suspense>
    </ErrorBoundary>
  );
}

๐ŸŒ Looking for a Dev Team That Actually Delivers?

Most agencies sell you a project manager and assign juniors. Viprasol is different โ€” senior engineers only, direct Slack access, and a 5.0โ˜… Upwork record across 100+ projects.

  • React, Next.js, Node.js, TypeScript โ€” production-grade stack
  • Fixed-price contracts โ€” no surprise invoices
  • Full source code ownership from day one
  • 90-day post-launch support included

Streaming SSR with Next.js App Router

In Next.js App Router, every async Server Component inside a <Suspense> boundary is streamed:

// src/app/product/[id]/page.tsx
// Demonstrates streaming: fast content first, slow content later

import { Suspense } from "react";

// Fast: static or cached data
async function ProductHeader({ productId }: { productId: string }) {
  const product = await getProduct(productId); // Cached, fast
  return (
    <div>
      <h1 className="text-3xl font-bold">{product.name}</h1>
      <p className="text-2xl font-semibold">${product.price}</p>
    </div>
  );
}

// Slow: real-time inventory check (can't be cached)
async function InventoryBadge({ productId }: { productId: string }) {
  const inventory = await checkInventory(productId); // Slow, uncached
  return (
    <span className={inventory.inStock ? "badge-green" : "badge-red"}>
      {inventory.inStock ? `${inventory.count} in stock` : "Out of stock"}
    </span>
  );
}

// Slow: personalized recommendations
async function Recommendations({ productId, userId }: { productId: string; userId: string }) {
  const recs = await getRecommendations(productId, userId); // Slow ML call
  return (
    <ul>
      {recs.map((rec) => (
        <li key={rec.id}>{rec.name}</li>
      ))}
    </ul>
  );
}

export default function ProductPage({
  params,
}: {
  params: { id: string };
}) {
  const userId = getUserId(); // From cookies/session

  return (
    <main>
      {/* Renders immediately โ€” no Suspense needed for fast content */}
      <Suspense fallback={<ProductHeaderSkeleton />}>
        <ProductHeader productId={params.id} />
      </Suspense>

      {/* Streams in when inventory check completes */}
      <Suspense fallback={<div className="h-6 w-24 animate-pulse bg-gray-200 rounded" />}>
        <InventoryBadge productId={params.id} />
      </Suspense>

      {/* Streams in last โ€” doesn't block the rest of the page */}
      <section>
        <h2>You might also like</h2>
        <Suspense fallback={<RecommendationsSkeleton />}>
          <Recommendations productId={params.id} userId={userId} />
        </Suspense>
      </section>
    </main>
  );
}

Avoiding Waterfall Fetches

Waterfall = sequential fetches where each waits for the previous. The fix: parallel data fetching with Promise.all:

// src/app/dashboard/page.tsx

// BAD: Sequential โ€” total time = 150ms + 300ms + 500ms = 950ms
async function DashboardBad() {
  const user = await getUser();          // 150ms
  const projects = await getProjects();  // 300ms โ€” waits for user
  const stats = await getStats();        // 500ms โ€” waits for projects
  return <Dashboard user={user} projects={projects} stats={stats} />;
}

// GOOD: Parallel โ€” total time = max(150ms, 300ms, 500ms) = 500ms
async function DashboardGood() {
  const [user, projects, stats] = await Promise.all([
    getUser(),
    getProjects(),
    getStats(),
  ]);
  return <Dashboard user={user} projects={projects} stats={stats} />;
}

// BETTER: Parallel + streaming โ€” each section appears as its data arrives
async function DashboardBest() {
  // Initiate all fetches immediately (don't await yet)
  // Pass the Promises to child components
  const userPromise = getUser();
  const projectsPromise = getProjects();
  const statsPromise = getStats();

  return (
    <div>
      <Suspense fallback={<UserSkeleton />}>
        {/* React.use() unwraps the Promise inside the component */}
        <UserSection userPromise={userPromise} />
      </Suspense>
      <Suspense fallback={<StatsSkeleton />}>
        <StatsSection statsPromise={statsPromise} />
      </Suspense>
      <Suspense fallback={<ProjectsSkeleton />}>
        <ProjectsSection projectsPromise={projectsPromise} />
      </Suspense>
    </div>
  );
}

// Child component using React.use() (React 19)
async function StatsSection({
  statsPromise,
}: {
  statsPromise: Promise<Stats>;
}) {
  const stats = await statsPromise; // In Next.js App Router, just await directly
  return <StatsGrid stats={stats} />;
}

๐Ÿš€ Senior Engineers. No Junior Handoffs. Ever.

You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.

  • MVPs in 4โ€“8 weeks, full platforms in 3โ€“5 months
  • Lighthouse 90+ performance scores standard
  • Works across US, UK, AU timezones
  • Free 30-min architecture review, no commitment

React Query with Suspense

// src/hooks/useProjectsSuspense.ts
"use client";

import { useSuspenseQuery, useSuspenseInfiniteQuery } from "@tanstack/react-query";

// useSuspenseQuery: component suspends while loading, throws on error
// No need for isLoading/isError checks โ€” Suspense/ErrorBoundary handle them
export function ProjectsList() {
  const { data } = useSuspenseQuery({
    queryKey: ["projects"],
    queryFn: fetchProjects,
    // staleTime: data is fresh for 60 seconds, no refetch needed
    staleTime: 60_000,
  });

  // data is guaranteed to be defined here โ€” no null check needed
  return (
    <ul>
      {data.projects.map((project) => (
        <li key={project.id}>{project.name}</li>
      ))}
    </ul>
  );
}

// Wrap in boundaries at the call site:
export function ProjectsSection() {
  return (
    <AsyncBoundary
      loadingFallback={<ProjectsSkeleton />}
      errorFallback={(error, reset) => (
        <div>
          <p>Failed to load projects: {error.message}</p>
          <button onClick={reset}>Retry</button>
        </div>
      )}
    >
      <ProjectsList />
    </AsyncBoundary>
  );
}

Deferred Transitions (Non-Urgent Updates)

// src/components/SearchResults.tsx
"use client";

import { useState, useTransition, Suspense } from "react";

// useTransition marks state updates as non-urgent
// The current UI stays interactive while the transition is pending
export function SearchPage() {
  const [query, setQuery] = useState("");
  const [deferredQuery, setDeferredQuery] = useState("");
  const [isPending, startTransition] = useTransition();

  function handleSearch(e: React.ChangeEvent<HTMLInputElement>) {
    const value = e.target.value;
    setQuery(value); // Urgent: input stays responsive

    startTransition(() => {
      setDeferredQuery(value); // Non-urgent: results update can be deferred
    });
  }

  return (
    <div>
      <input
        value={query}
        onChange={handleSearch}
        placeholder="Search projects..."
        className="w-full border px-3 py-2 rounded"
      />
      {/* dim results while transition is pending โ€” not a full loading spinner */}
      <div className={isPending ? "opacity-50 pointer-events-none" : ""}>
        <Suspense fallback={<SearchSkeleton />}>
          <SearchResults query={deferredQuery} />
        </Suspense>
      </div>
    </div>
  );
}

Common Suspense Mistakes

MistakeSymptomFix
No error boundary with SuspenseThrown errors crash the whole pageAlways pair <Suspense> with <ErrorBoundary>
One boundary for all contentEverything waits for the slowest fetchGranular boundaries per section
Fetching inside useEffectNo streaming benefit, waterfall riskFetch in Server Components or useSuspenseQuery
Awaiting sequentially in Server ComponentWaterfall: total time = sum of all waitsPromise.all() or start promises before awaiting
Missing key prop on dynamic SuspenseOld fallback shown for new contentAdd key={id} to force boundary reset on navigation

See Also


Working With Viprasol

React Suspense, when used correctly, dramatically improves perceived performance โ€” users see content progressively rather than waiting for the slowest query. Our React engineers design Suspense boundary trees that match your data dependencies, integrate with React Query and Next.js streaming, and ensure every boundary has proper error recovery.

React engineering services โ†’ | Start a project โ†’

Share this article:

About the Author

V

Viprasol Tech Team

Custom Software Development Specialists

The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Need a Modern Web Application?

From landing pages to complex SaaS platforms โ€” we build it all with Next.js and React.

Free consultation โ€ข No commitment โ€ข Response within 24 hours

Viprasol ยท Web Development

Need a custom web application built?

We build React and Next.js web applications with Lighthouse โ‰ฅ90 scores, mobile-first design, and full source code ownership. Senior engineers only โ€” from architecture through deployment.