React Suspense & Lazy Loading

1. 📖 Introduction

Suspense is one of React's most powerful — and least understood — features. It lets a component "pause" rendering while it waits for something, showing a fallback UI in the meantime. Paired with React.lazy, it enables code splitting: downloading only the JS a user actually needs, exactly when they need it. This tutorial covers Suspense's mechanics, lazy-loaded components and routes, data fetching, and how it fits into React's broader concurrent rendering model.

Information

Suspense for lazy-loaded components is stable and widely used today. Suspense for data fetching depends on the library or framework you use — this guide covers both, noting where support varies.

2. ❓ What is Suspense?

<Suspense> is a component that lets you display a fallback UI while its children aren't yet ready to render — whether that's because their code hasn't loaded yet, or their data hasn't arrived.

SuspenseBasic.jsx

import { Suspense } from "react";

function App() {
  return (
    <Suspense fallback={<p>Loading...</p>}>
      <ProfilePage />
    </Suspense>
  );
}

3. 🤔 Why Use Suspense?

  • Avoids manually tracking a loading boolean in every component that needs to wait for something
  • Enables code splitting, shrinking the initial JS bundle users must download
  • Provides a single, consistent place to show loading UI for an entire subtree, rather than scattering spinners everywhere
  • Composes naturally with Error Boundaries for a complete loading/error/success story

4. âš™ī¸ How Suspense Works

Under the hood, a component "suspends" by throwing a Promise during rendering. React catches that Promise, pauses the subtree, shows the nearest <Suspense> boundary's fallback, and retries rendering once the Promise resolves.

Component renders
Resource not ready → throws a Promise
Nearest Suspense boundary catches it
Fallback UI shown
Promise resolves → React retries render
Real content displayed

Note

You rarely throw a Promise by hand — React.lazy and Suspense-compatible data libraries do this internally on your behalf.

5. 🚧 Suspense Boundaries

A Suspense boundary is just the nearest <Suspense> ancestor of a suspending component. Placement matters: a boundary wrapped tightly around one component isolates its loading state, while a boundary higher up covers a whole section at once.

6. 🎨 Fallback UI

The fallback prop can be any JSX — a spinner, a skeleton screen, or simple text — shown for as long as the boundary's children remain suspended.

FallbackUI.jsx

<Suspense fallback={<SkeletonCard />}>
  <ProductDetails productId={id} />
</Suspense>

Tip

Skeleton screens that roughly match the shape of the real content tend to feel faster to users than a generic spinner, since they reduce perceived layout shift.

7. đŸĒ† Nested Suspense

Suspense boundaries can be nested. An inner boundary catches suspensions from its own children first; only if there's no inner boundary does the suspension propagate up to an outer one.

NestedSuspense.jsx

<Suspense fallback={<PageSkeleton />}>
  <Header />
  <Suspense fallback={<SidebarSkeleton />}>
    <Sidebar />
  </Suspense>
  <MainContent />
</Suspense>

8. 🧩 Multiple Suspense Boundaries

Using several independent boundaries lets different parts of a page load and reveal themselves separately, rather than the whole page waiting on the single slowest piece.

MultipleBoundaries.jsx

function Dashboard() {
  return (
    <div className="grid">
      <Suspense fallback={<CardSkeleton />}>
        <RevenueCard />
      </Suspense>
      <Suspense fallback={<CardSkeleton />}>
        <UsersCard />
      </Suspense>
      <Suspense fallback={<CardSkeleton />}>
        <ActivityFeed />
      </Suspense>
    </div>
  );
}

Best Practice

Splitting a dashboard into multiple boundaries means a slow ActivityFeed request doesn't block the RevenueCard from appearing as soon as it's ready.

9. đŸĻĨ React.lazy

React.lazy wraps a dynamic import() call so a component's code is only downloaded the first time it's actually rendered.

ReactLazy.jsx

const SettingsPanel = React.lazy(() => import("./SettingsPanel"));

Important

React.lazy only works with default exports. For a named export, re-export it as default from a small wrapper module, or use .then() to remap it: import("./file").then(m => ({ default: m.NamedExport })).

10. đŸ“Ļ Dynamic Imports

A dynamic import() returns a Promise that resolves to a module, and is the underlying browser/bundler feature that both React.lazy and manual code splitting rely on.

DynamicImport.jsx

async function loadChartLibrary() {
  const { Chart } = await import("./chartLibrary");
  return Chart;
}

11. 🧩 Lazy Loading Components

Any component — not just entire pages — can be lazy-loaded, which is especially useful for heavy, conditionally-rendered UI like modals, rich editors, or charting widgets.

LazyComponent.jsx

const ImageEditor = React.lazy(() => import("./ImageEditor"));

function Gallery() {
  const [editing, setEditing] = useState(false);

  return (
    <>
      <button onClick={() => setEditing(true)}>Edit image</button>
      {editing && (
        <Suspense fallback={<Spinner />}>
          <ImageEditor />
        </Suspense>
      )}
    </>
  );
}

12. đŸ›Ŗī¸ Lazy Loading Routes

Combining React.lazy with React Router lets each page's code download only when the user actually navigates there, rather than bundling every page into the initial load.

LazyRoutes.jsx

const Dashboard = React.lazy(() => import("./pages/Dashboard"));
const Settings = React.lazy(() => import("./pages/Settings"));

<Routes>
  <Route
    path="/dashboard"
    element={
      <Suspense fallback={<PageSpinner />}>
        <Dashboard />
      </Suspense>
    }
  />
  <Route
    path="/settings"
    element={
      <Suspense fallback={<PageSpinner />}>
        <Settings />
      </Suspense>
    }
  />
</Routes>

13. âœ‚ī¸ Code Splitting

Code splitting is the broader strategy: breaking one large JS bundle into smaller chunks that load independently. React.lazy plus Suspense is React's built-in mechanism for achieving this at the component level.

14. đŸ›¤ī¸ Route-Based Code Splitting

Splitting by route is the most common and highest-impact strategy — each page tends to be a natural, self-contained chunk that users only need when they visit it (see Section 12's example).

15. 🧱 Component-Based Code Splitting

Splitting by component targets specific heavy pieces within a page — like a rich text editor, a map, or a video player — that many visitors never actually trigger.

ComponentSplitting.jsx

const MapView = React.lazy(() => import("./MapView"));

function LocationPicker({ showMap }) {
  return showMap ? (
    <Suspense fallback={<MapSkeleton />}>
      <MapView />
    </Suspense>
  ) : (
    <AddressForm />
  );
}

16. đŸ“Ĩ Data Fetching with Suspense

Beyond code, Suspense can also coordinate data loading — a component "suspends" until its data resolves, letting you delete manual loading-state boilerplate. This requires a Suspense-compatible data source, such as certain configurations of TanStack Query or frameworks like Next.js and Relay.

SuspenseDataFetching.jsx

function ProductDetails({ productId }) {
  // useSuspenseQuery "throws" internally until data resolves
  const { data: product } = useSuspenseQuery({
    queryKey: ["product", productId],
    queryFn: () => fetchProduct(productId),
  });

  return <h1>{product.name}</h1>;
}

<Suspense fallback={<Spinner />}>
  <ProductDetails productId={id} />
</Suspense>

Caution

Plain fetch calls inside useEffect do not integrate with Suspense on their own — a library or framework must be specifically built to throw a Promise that Suspense can catch.

17. 🌊 Streaming with Suspense

Server-rendering frameworks (like Next.js) can stream HTML to the browser progressively — sending the shell immediately and streaming in each Suspense boundary's content as its data becomes ready on the server, rather than waiting for everything before responding.

18. 🚨 Error Handling with Suspense

Suspense handles the loading case, but not errors — a rejected Promise (a failed fetch, for instance) must be caught by a nearby Error Boundary, not by Suspense itself.

SuspenseWithErrorBoundary.jsx

<ErrorBoundary fallback={<p>Failed to load product.</p>}>
  <Suspense fallback={<Spinner />}>
    <ProductDetails productId={id} />
  </Suspense>
</ErrorBoundary>

19. âš›ī¸ Suspense and Concurrent Rendering

Suspense works hand-in-hand with React's concurrent rendering features. useTransition and startTransition let you navigate to a new suspending view while keeping the old content visible (and interactive) until the new content is ready, rather than immediately flashing a fallback.

TransitionWithSuspense.jsx

function Tabs() {
  const [tab, setTab] = useState("posts");
  const [isPending, startTransition] = useTransition();

  const selectTab = (nextTab) => {
    startTransition(() => setTab(nextTab));
  };

  return (
    <>
      <button onClick={() => selectTab("posts")}>Posts</button>
      <button onClick={() => selectTab("comments")}>Comments</button>
      <Suspense fallback={<Spinner />}>
        {tab === "posts" ? <Posts /> : <Comments />}
      </Suspense>
      {isPending && <span>Updating...</span>}
    </>
  );
}

20. 🏆 Suspense Best Practices

  1. Place boundaries deliberately — wrap independent sections separately so one slow piece doesn't block the rest
  2. Always pair Suspense with an Error Boundary nearby
  3. Use skeleton fallbacks that approximate real content's shape to reduce layout shift
  4. Reserve data-fetching Suspense for libraries/frameworks explicitly built to support it

21. ⚡ Performance Optimization

Lazy loading trades a slightly later render for a smaller initial bundle — the net win depends on how large the deferred code is and how often users actually need it. Route-based splitting (Section 14) usually offers the best return with the least complexity.

Tip

Prefetching a lazy route's code just before the user needs it — for example, on a link's onMouseEnter — can make navigation feel instant despite still being code-split.

22. 🔷 TypeScript with Suspense

Typing works the same for lazy components as any other — React.lazy preserves the wrapped component's prop types automatically.

TypedLazy.tsx

interface SettingsPanelProps {
  userId: string;
}

const SettingsPanel = React.lazy(() => import("./SettingsPanel")) as React.ComponentType<SettingsPanelProps>;

23. âš ī¸ Common Mistakes

  • Forgetting to wrap a lazy component in <Suspense>, causing an error when its code hasn't loaded yet
  • Assuming Suspense catches errors — it only handles the loading state, not rejections
  • Using React.lazy with a named export without remapping it to a default export first
  • Wrapping the entire app in one giant Suspense boundary, so any single slow piece blanks the whole page
  • Trying to make plain useEffect-based fetches "work" with Suspense without a compatible data library

Danger

A component using React.lazy without a surrounding <Suspense> boundary will throw at runtime the first time it renders — Suspense isn't optional decoration, it's a required part of the lazy-loading pattern.

24. đŸ’Ŧ Frequently Asked Questions

Does Suspense replace loading state entirely?

For code loading, yes. For data loading, only when paired with a Suspense-compatible library — plain fetch calls in useEffect still need manual loading state.

Can I nest a Suspense boundary inside another one?

Yes — nested boundaries are a common and encouraged pattern, letting inner content show its own fallback independently of the outer page shell.

Is Suspense only useful for code splitting?

No — while code splitting via React.lazy is the most universally supported use case, Suspense's design also supports coordinated data loading and streaming server rendering in frameworks built to take advantage of it.

25. 📌 Summary

>>Suspense turns "wait for this" into a first-class part of the component tree, instead of scattered loading flags everywhere.