Skip to content

Fix: React Suspense Not Working — Boundary Not Catching or Fallback Not Showing

FixDevs · (Updated: )

Part of:  React & Frontend Errors

Quick Answer

How to fix React Suspense boundaries not triggering — lazy() import syntax, use() hook, data fetching libraries, ErrorBoundary vs Suspense, and Next.js loading.tsx.

The Problem

A React <Suspense> boundary doesn’t show the fallback during loading:

<Suspense fallback={<Spinner />}>
  <UserProfile userId={42} />  {/* Spinner never shows */}
</Suspense>

Or a lazy-loaded component triggers an error instead of a loading state:

Error: Element type is invalid: expected a string or a class/function
but got: object. You may have forgotten to use lazy() correctly.

Or the Suspense fallback shows, but never resolves to the actual component:

// Component renders but stays on the fallback indefinitely

Or in Next.js App Router, loading.tsx doesn’t appear during navigation:

// Users see no loading indicator when navigating between pages

Why This Happens

React Suspense works by catching “promises” thrown by components. Only specific patterns trigger Suspense:

  • React.lazy() — lazy-loaded components throw a promise until the import resolves. This is the one case that works out-of-the-box.
  • Data fetching libraries with Suspense support — React Query, SWR, and Relay can throw promises when suspense: true is enabled in their config.
  • React’s use() hook — available in React 19. use(promise) suspends the component until the promise resolves.
  • Custom Suspense-compatible data fetching — requires manually throwing a promise from the component, which is non-trivial.

The behavior also varies significantly across React versions and frameworks. React 18 introduced concurrent rendering, which unlocked Suspense for data fetching beyond just React.lazy(). React 19 added the use() hook and refined how Suspense boundaries interact with Server Components. If you’re on React 17 or earlier, Suspense only works with React.lazy() and nothing else. Framework differences add another layer: Next.js App Router uses Suspense under the hood for its streaming architecture, while the Pages Router doesn’t support it at all. Remix uses its own deferred loader pattern that integrates with Suspense differently than raw React. React Native, meanwhile, has no built-in Suspense support for data fetching as of React Native 0.76.

Common mistakes:

  • Using async components directlyasync function Component() doesn’t work in React 18 client components. It only works in React Server Components (Next.js App Router).
  • lazy() with wrong import syntaxReact.lazy() requires a function that returns a dynamic import(), not the import result directly.
  • Missing Suspense wrapper — if a lazy component is rendered without a Suspense ancestor, React throws an error instead of showing a fallback.
  • ErrorBoundary catching Suspense exceptionsErrorBoundary catches all thrown values. If it wraps a Suspense, it may catch the promise and show an error state instead.

Fix 1: Use React.lazy() Correctly

React.lazy() is the most reliable way to trigger Suspense — use it for code-split components:

import React, { Suspense, lazy } from 'react';

// CORRECT — lazy() wraps a function that returns a dynamic import
const UserProfile = lazy(() => import('./UserProfile'));
const Dashboard = lazy(() => import('./Dashboard'));

// WRONG — passing the import result directly (not a function)
const UserProfile = lazy(import('./UserProfile'));  // TypeError

// WRONG — the dynamic import doesn't return a default export
// import('./utils') exports { helper } but no default
const Utils = lazy(() => import('./utils'));  // Error: no default export

// Fix for named exports — re-export as default or wrap:
const Utils = lazy(() =>
  import('./utils').then(module => ({ default: module.NamedComponent }))
);

// Usage — must be wrapped in Suspense
function App() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <UserProfile userId={42} />
    </Suspense>
  );
}

Lazy load with error handling:

import { Suspense, lazy } from 'react';
import ErrorBoundary from './ErrorBoundary';

const HeavyChart = lazy(() => import('./HeavyChart'));

function Dashboard() {
  return (
    <ErrorBoundary fallback={<p>Failed to load chart.</p>}>
      <Suspense fallback={<p>Loading chart...</p>}>
        <HeavyChart />
      </Suspense>
    </ErrorBoundary>
  );
}

Multiple lazy components with a single Suspense:

const UserList = lazy(() => import('./UserList'));
const UserStats = lazy(() => import('./UserStats'));

// Both resolve before the fallback disappears
function App() {
  return (
    <Suspense fallback={<FullPageSpinner />}>
      <UserList />
      <UserStats />
    </Suspense>
  );
}

Bundler differences with React.lazy(): Webpack and Vite handle the dynamic import() differently. Webpack uses webpackChunkName magic comments to name chunks (import(/* webpackChunkName: "dashboard" */ './Dashboard')). Vite ignores these comments entirely and generates chunk names from the file path. If you migrate from Webpack to Vite, your named chunks disappear and prefetch hints break. Vite also uses native ES module dynamic imports in dev mode, which means lazy-loaded components resolve almost instantly during development but show real loading behavior in production builds. This discrepancy can hide Suspense fallback bugs that only surface in production.

Fix 2: Enable Suspense in React Query or SWR

React Query and SWR have Suspense support that must be explicitly enabled:

React Query (TanStack Query) v5:

import { useSuspenseQuery } from '@tanstack/react-query';

// Use useSuspenseQuery instead of useQuery
function UserProfile({ userId }) {
  // useSuspenseQuery throws a promise while fetching — triggers Suspense
  const { data: user } = useSuspenseQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
  });

  // No need to check for isLoading — component only renders when data is ready
  return <div>{user.name}</div>;
}

// Wrap with Suspense
function App() {
  return (
    <Suspense fallback={<Spinner />}>
      <UserProfile userId={42} />
    </Suspense>
  );
}

React Query v4 (legacy):

// v4 — enable suspense per-query or globally
const { data } = useQuery({
  queryKey: ['user', userId],
  queryFn: fetchUser,
  suspense: true,   // Enable suspense mode
});

SWR:

import useSWR from 'swr';

function UserProfile({ userId }) {
  const { data: user } = useSWR(`/api/users/${userId}`, fetcher, {
    suspense: true,   // Throw promise while loading
  });

  return <div>{user.name}</div>;
}

// Wrap with Suspense
<Suspense fallback={<Spinner />}>
  <UserProfile userId={42} />
</Suspense>

Fix 3: Use the use() Hook (React 19)

React 19 introduces use() for reading promises and context in components:

import { use, Suspense } from 'react';

// Create the promise outside the component (or pass as prop)
const userPromise = fetchUser(42);

function UserProfile({ userPromise }) {
  // use() suspends the component until the promise resolves
  const user = use(userPromise);

  return <div>{user.name}</div>;
}

function App() {
  const [userPromise] = useState(() => fetchUser(42));

  return (
    <Suspense fallback={<Spinner />}>
      <UserProfile userPromise={userPromise} />
    </Suspense>
  );
}

Note: Don’t create the promise inside the component function — that creates a new promise on every render, causing infinite loading. Create the promise outside the component or use useMemo/useState to stabilize it.

Wrong pattern — promise created inside render:

// WRONG — new promise on every render = infinite loading
function UserProfile({ userId }) {
  const user = use(fetchUser(userId));  // fetchUser called on every render
  return <div>{user.name}</div>;
}

// CORRECT — stable promise reference
function UserProfile({ userPromise }) {
  const user = use(userPromise);   // Stable promise passed as prop
  return <div>{user.name}</div>;
}

React 18 vs React 19 difference: In React 18, there is no use() hook. If you need Suspense-based data fetching in React 18, you must use a library like React Query with useSuspenseQuery or build a custom resource that throws promises. Upgrading to React 19 simplifies this significantly, but the use() hook only works in client components. Server Components in Next.js can use async/await directly without use().

Fix 4: Fix ErrorBoundary and Suspense Interaction

ErrorBoundary and Suspense must be correctly ordered. An ErrorBoundary that wraps Suspense catches Suspense promises if improperly implemented:

// Custom ErrorBoundary — must NOT catch promises (React handles those)
class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false, error: null };
  }

  static getDerivedStateFromError(error) {
    // ONLY catch actual errors — React internally throws promises for Suspense
    // React 18+ filters promise throws from ErrorBoundary automatically
    return { hasError: true, error };
  }

  componentDidCatch(error, info) {
    console.error('Caught error:', error, info);
  }

  render() {
    if (this.state.hasError) {
      return this.props.fallback || <p>Something went wrong.</p>;
    }
    return this.props.children;
  }
}

// Correct nesting: ErrorBoundary wraps Suspense
// Suspense handles loading, ErrorBoundary handles load failures
function SafeUserProfile({ userId }) {
  return (
    <ErrorBoundary fallback={<p>Failed to load user.</p>}>
      <Suspense fallback={<Spinner />}>
        <UserProfile userId={userId} />
      </Suspense>
    </ErrorBoundary>
  );
}

Use react-error-boundary for a robust ErrorBoundary:

npm install react-error-boundary
import { ErrorBoundary } from 'react-error-boundary';
import { Suspense } from 'react';

function ErrorFallback({ error, resetErrorBoundary }) {
  return (
    <div role="alert">
      <p>Something went wrong:</p>
      <pre>{error.message}</pre>
      <button onClick={resetErrorBoundary}>Try again</button>
    </div>
  );
}

function App() {
  return (
    <ErrorBoundary FallbackComponent={ErrorFallback}>
      <Suspense fallback={<Spinner />}>
        <UserProfile userId={42} />
      </Suspense>
    </ErrorBoundary>
  );
}

Fix 5: Fix Suspense in Next.js App Router

Next.js 13+ App Router has built-in Suspense support through loading.tsx files and <Suspense> components:

loading.tsx — automatic Suspense for the whole route segment:

// app/dashboard/loading.tsx
// This file automatically wraps app/dashboard/page.tsx in a Suspense boundary
export default function DashboardLoading() {
  return (
    <div className="animate-pulse">
      <div className="h-8 bg-gray-200 rounded w-1/4 mb-4" />
      <div className="h-64 bg-gray-200 rounded" />
    </div>
  );
}
// app/dashboard/page.tsx — can be async in Server Components
async function DashboardPage() {
  // Data fetching happens here — Next.js handles Suspense automatically
  const data = await fetchDashboardData();
  return <Dashboard data={data} />;
}

Manual <Suspense> for partial loading states:

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

export default function DashboardPage() {
  return (
    <div>
      <h1>Dashboard</h1>
      {/* Sidebar loads independently */}
      <Suspense fallback={<SidebarSkeleton />}>
        <Sidebar />
      </Suspense>

      {/* Main content loads independently */}
      <Suspense fallback={<ContentSkeleton />}>
        <MainContent />
      </Suspense>
    </div>
  );
}

Client Components in App Router still need lazy() for code splitting:

'use client';

import { lazy, Suspense } from 'react';

const HeavyEditor = lazy(() => import('./HeavyEditor'));

export function EditorPage() {
  return (
    <Suspense fallback={<p>Loading editor...</p>}>
      <HeavyEditor />
    </Suspense>
  );
}

Fix 6: Platform and Framework Differences

Suspense behavior varies across frameworks and environments. Understanding these differences prevents debugging the wrong layer.

Next.js App Router (streaming) vs Pages Router:

The App Router streams HTML with Suspense boundaries. When a Server Component awaits data, Next.js sends the shell immediately and streams the resolved content later. The Pages Router does not support this at all. If you migrate from Pages Router to App Router, loading.tsx files only work inside the app/ directory. Pages Router routes must use client-side loading states (e.g., React Query’s isLoading).

// App Router — streaming works automatically
// app/users/page.tsx
export default async function UsersPage() {
  const users = await getUsers(); // Suspense boundary from loading.tsx handles wait
  return <UserList users={users} />;
}

// Pages Router — NO Suspense for data fetching
// pages/users.tsx — must use getServerSideProps or client-side fetching
export default function UsersPage({ users }) {
  return <UserList users={users} />;
}
export async function getServerSideProps() {
  const users = await getUsers();
  return { props: { users } };
}

Remix deferred loaders:

Remix uses defer() and <Await> instead of raw <Suspense> for data streaming. The pattern is different from both Next.js and vanilla React:

// Remix loader — defer non-critical data
import { defer } from '@remix-run/node';
import { Await, useLoaderData } from '@remix-run/react';
import { Suspense } from 'react';

export async function loader() {
  const criticalData = await getCriticalData();    // Awaited — blocks render
  const slowData = getSlowData();                  // NOT awaited — streamed later
  return defer({ criticalData, slowData });
}

export default function Page() {
  const { criticalData, slowData } = useLoaderData();
  return (
    <div>
      <h1>{criticalData.title}</h1>
      <Suspense fallback={<p>Loading comments...</p>}>
        <Await resolve={slowData}>
          {(data) => <CommentList comments={data.comments} />}
        </Await>
      </Suspense>
    </div>
  );
}

React Native limitations:

React Native does not support Suspense for data fetching. React.lazy() does not work in React Native because there is no dynamic import() equivalent for native modules. You can use Suspense only with libraries that explicitly support it in React Native (e.g., Relay). For data loading states in React Native, use isLoading flags from your data fetching library.

Fix 7: Build a Simple Suspense-Compatible Data Source

If you’re not using a library with Suspense support, here’s the pattern for building a Suspense-compatible data source:

// A simple "resource" that throws a promise until data is ready
type Resource<T> =
  | { status: 'pending'; promise: Promise<void> }
  | { status: 'success'; data: T }
  | { status: 'error'; error: Error };

function createResource<T>(promise: Promise<T>): { read: () => T } {
  let resource: Resource<T> = {
    status: 'pending',
    promise: promise.then(
      (data) => { resource = { status: 'success', data }; },
      (error) => { resource = { status: 'error', error }; }
    ),
  };

  return {
    read(): T {
      if (resource.status === 'pending') {
        throw resource.promise;    // Suspense catches this
      }
      if (resource.status === 'error') {
        throw resource.error;      // ErrorBoundary catches this
      }
      return resource.data;
    },
  };
}

// Usage
const userResource = createResource(fetchUser(42));

function UserProfile() {
  const user = userResource.read();  // Throws until data is ready
  return <div>{user.name}</div>;
}

Note: This pattern is educational. In production, use React Query, SWR, or React’s use() hook (React 19) instead.

Still Not Working?

Suspense in React 17 and earlierReact.lazy() is supported in React 16.6+, but use() and SuspenseList require React 18+. Data fetching Suspense (beyond lazy) wasn’t stable until React 18.

Concurrent Mode required for full Suspense — React 18’s createRoot enables concurrent mode. If you’re using ReactDOM.render() (legacy mode), Suspense support is limited to lazy loading only.

Fallback flicker — if the loading state resolves too quickly, the fallback briefly shows and disappears, causing a flash. Add a minimum delay or use startTransition to defer showing the loading state:

import { startTransition, useState } from 'react';

function Navigation() {
  const [page, setPage] = useState('home');

  function navigate(newPage) {
    startTransition(() => {
      setPage(newPage);   // Suspense transition — no fallback flash for fast loads
    });
  }
  // ...
}

Nested Suspense boundaries — React resolves the nearest ancestor Suspense boundary. A Suspense inside another Suspense only shows the inner fallback. If both are loading, only the outer fallback shows until the inner component starts rendering.

SuspenseList removed from React 19 — if you used <SuspenseList> in experimental React builds to coordinate multiple Suspense boundaries (controlling reveal order), this API was removed in the React 19 stable release. Use individual <Suspense> boundaries and coordinate loading states manually, or rely on your framework’s streaming order.

Suspense fallback shows in SSR but not in client navigation — in Next.js App Router, loading.tsx only triggers during server-rendered navigations. Client-side soft navigations using <Link> may skip the loading state if the route segment is already cached. Clear the router cache with router.refresh() to force a re-fetch, or use <Suspense> boundaries inside the page component for data that changes between visits.

Code-split chunks fail to load on slow networks — when a React.lazy() chunk fails to download (network error, 404 after deployment), Suspense doesn’t show the fallback. Instead, the ErrorBoundary catches the chunk load error. Handle this by adding retry logic to the dynamic import:

const Dashboard = lazy(() =>
  import('./Dashboard').catch(() => {
    // Retry once after a short delay (handles deploy-time chunk invalidation)
    return new Promise(resolve => setTimeout(resolve, 1500))
      .then(() => import('./Dashboard'));
  })
);

For related React issues, see Fix: React Hydration Error, Fix: React Query Stale Data, Fix: React Lazy Suspense Error, and Fix: React Server Components Error.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles