Topics in this subject
React 3 min read Updated 5 Aug 2026

Suspense & Code Splitting

Lazy-load components with React.lazy and Suspense, split by route, and show fallbacks while chunks resolve.

The core idea#

Code splitting ships less JS upfront by loading components on demand. React.lazy wraps a dynamic import(); <Suspense> shows a fallback while the chunk (or data) is loading. When a lazy component "suspends", React walks up to the nearest <Suspense> boundary and renders its fallback until the promise resolves. 🎯

import { lazy, Suspense } from "react";

const Dashboard = lazy(() => import("./Dashboard")); // separate bundle chunk

function App() {
  return (
    <Suspense fallback={<Spinner />}>
      <Dashboard />
    </Suspense>
  );
}

React.lazy requires the module to have a default export that is a component.

Route-based splitting#

The highest-leverage split point — each route becomes its own chunk, so users only download the page they visit.

const Home = lazy(() => import("./routes/Home"));
const Settings = lazy(() => import("./routes/Settings"));

<Suspense fallback={<PageSkeleton />}>
  <Routes>
    <Route path="/" element={<Home />} />
    <Route path="/settings" element={<Settings />} />
  </Routes>
</Suspense>

Pairing with Error Boundaries#

A chunk can fail to load (network drop, deploy invalidating old hashes). Wrap Suspense in an error boundary so a failed import shows a retry, not a blank screen. 🟢

<ErrorBoundary FallbackComponent={ChunkError}>
  <Suspense fallback={<Spinner />}>
    <Dashboard />
  </Suspense>
</ErrorBoundary>
Boundary Handles
<Suspense> The pending/loading state
<ErrorBoundary> The failed/rejected state

Suspense for data#

Suspense also orchestrates data loading, but you don't wire promises directly — a framework or library integrates with it: React Server Components / Next.js App Router, TanStack Query's useSuspenseQuery, Relay, or React Router loaders. Those throw a promise on read; Suspense shows the fallback until it resolves. ⚠️ Reading a raw promise with the use hook (React 19) also suspends, but arbitrary fetch-on-render without a cache is not a supported pattern.

flowchart TD
  A["Render reaches lazy component"] --> B["Trigger dynamic import()"]
  B --> C["Component suspends (throws promise)"]
  C --> D["Nearest Suspense shows fallback"]
  D --> E{"Promise settles"}
  E -->|"resolved"| F["Render real component"]
  E -->|"rejected"| G["Error boundary catches"]

Interview Q&A#

Q1. What does React.lazy return and what does it require? It returns a special component backed by a dynamic import(). The imported module must expose the component as its default export. Rendering it suspends until the chunk downloads.

Q2. What happens when a lazy component suspends? React unwinds to the nearest <Suspense> boundary and renders its fallback. When the import's promise resolves, React retries the render and swaps in the real component.

Q3. Why pair Suspense with an error boundary? Suspense only handles the pending state. A network failure or a stale chunk hash after a deploy rejects the import promise — an error boundary catches that so you can show a retry instead of a broken UI.

Q4. Where should you place Suspense boundaries? At meaningful UI seams — typically per route, and around independently loading sections. Coarser boundaries mean bigger fallback flashes; too many nested boundaries cause layout jank. Match boundaries to what can load independently.

Q5. Can Suspense handle data fetching, not just code? Yes, but through integrations: RSC/Next App Router, useSuspenseQuery (TanStack Query), Relay, router loaders, or React 19's use(promise). These throw a cached promise on read; Suspense shows the fallback until it resolves.