Lazy Loading and Suspense
By default, a React app bundled with a tool like Vite or webpack ships as one big JavaScript file. Every component your app could ever render — including the settings page a user may never open, or the chart library used on one rarely-visited dashboard — gets downloaded and parsed before the first pixel appears. Lazy loading lets you split that bundle into smaller chunks and fetch each chunk only when it’s actually needed, and Suspense is the mechanism React gives you to show a fallback UI while the browser fetches that chunk. Together they can dramatically shrink your initial load time without changing how your components are written.
Overview / How it works
Modern bundlers understand the dynamic import() syntax: instead of import Chart from "./Chart.jsx" at the top of a file (a static import, resolved at build time and bundled immediately), you can write import("./Chart.jsx") as a function call. This returns a Promise that resolves to the module once the browser has fetched and parsed it. Bundlers detect this pattern and automatically split that module — and everything it imports that nothing else needs — into its own separate file, often called a “chunk”.
React.lazy() wraps that dynamic import so the result can be rendered as a normal component. It takes a function that returns a dynamic import() and returns a special component. The first time React tries to render that component, it calls the function, gets back a pending Promise, and — because it isn’t ready yet — the component suspends. Suspending means React walks up the tree looking for the nearest Suspense boundary and renders that boundary’s fallback instead, without throwing an error or breaking the rest of the page. When the Promise resolves (the chunk has downloaded), React discards the fallback and renders the real component in its place, using the same reconciliation process as any other update.
This is important: Suspense is not itself the thing that downloads code. It’s a boundary that says “if anything below me isn’t ready yet, show this fallback instead of crashing or rendering half a tree.” React.lazy is one producer of that “not ready yet” signal. Other data-fetching libraries (like React Router’s data APIs, or Relay) can also integrate with Suspense, but plain useEffect-based fetching does not — only lazy() and libraries specifically built for Suspense will trigger it.
Because splitting happens per dynamic import() call, the standard pattern is to lazy-load at natural seams in your UI: whole routes/pages, modals that open rarely, heavy third-party widgets (charts, rich text editors, maps), or admin-only screens most users never see. You generally do not lazy-load small, always-visible components like a Button or Avatar — the network round trip and extra chunk overhead cost more than they save.
Syntax
import { lazy, Suspense } from "react";
const LazyComponent = lazy(() => import("./LazyComponent.jsx"));
function Parent() {
return (
<Suspense fallback={<p>Loading...</p>}>
<LazyComponent />
</Suspense>
);
}
| Part | Purpose |
|---|---|
lazy(loader) |
Takes a function returning a dynamic import() promise; returns a component you can render like any other. |
| the imported module | Must have a default export — that’s what lazy renders. |
<Suspense> |
A component that catches suspension from any lazy descendant and shows fallback until it resolves. |
fallback |
Any JSX to render while waiting — a spinner, skeleton, or simple text. |
Examples
Example 1: Lazy-loading a single heavy component
// HeavyChart.jsx
export default function HeavyChart() {
return <div>Rendering a complex chart with a large charting library...</div>;
}
// App.jsx
import { lazy, Suspense } from "react";
const HeavyChart = lazy(() => import("./HeavyChart.jsx"));
function App() {
return (
<div>
<h1>Dashboard</h1>
<Suspense fallback={<p>Loading chart...</p>}>
<HeavyChart />
</Suspense>
</div>
);
}
export default App;
Renders: immediately shows an <h1> reading “Dashboard” plus “Loading chart…” text. As soon as the browser finishes fetching the HeavyChart.jsx chunk (often a few milliseconds on a fast connection, longer on slow ones), React swaps the fallback for the real chart div reading “Rendering a complex chart with a large charting library…”. The HeavyChart code is never included in the app’s main bundle — it lives in its own file that the browser only requests when this component is about to render.
Example 2: Route-based code splitting with React Router
import { lazy, Suspense } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter, Routes, Route } from "react-router-dom";
const Home = lazy(() => import("./pages/Home.jsx"));
const About = lazy(() => import("./pages/About.jsx"));
const Dashboard = lazy(() => import("./pages/Dashboard.jsx"));
function App() {
return (
<BrowserRouter>
<Suspense fallback={<p>Loading page...</p>}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/dashboard" element={<Dashboard />} />
</Routes>
</Suspense>
</BrowserRouter>
);
}
createRoot(document.getElementById("root")).render(<App />);
This is the single most common use of lazy loading in real apps. Each page becomes its own chunk, so a visitor who only ever looks at the home page never downloads the code for /dashboard. One Suspense wrapped around <Routes> covers every route, showing “Loading page…” briefly during route transitions to a page whose chunk hasn’t been fetched yet.
Example 3: Multiple independent Suspense boundaries
import { lazy, Suspense, useState } from "react";
const Profile = lazy(() => import("./Profile.jsx"));
const Notifications = lazy(() => import("./Notifications.jsx"));
function App() {
const [showNotifications, setShowNotifications] = useState(false);
return (
<div>
<Suspense fallback={<p>Loading profile...</p>}>
<Profile />
</Suspense>
<button onClick={() => setShowNotifications(true)}>
Show notifications
</button>
{showNotifications && (
<Suspense fallback={<p>Loading notifications...</p>}>
<Notifications />
</Suspense>
)}
</div>
);
}
export default App;
Giving each lazy component its own Suspense boundary means a slow-loading one doesn’t block a fast one. Here, Profile loads independently of Notifications, which is only fetched at all once the user clicks the button — a form of lazy loading triggered by user interaction rather than by navigation.
How it works step by step / Under the hood
- On first render: React encounters the lazy component, calls its loader function, and gets back a pending Promise. React records that this render “suspended”, throws that Promise up the tree internally, and looks for the nearest
Suspenseancestor. It commits thefallbackto the DOM instead of the real content. - While waiting: the rest of the page outside that
Suspenseboundary continues to work normally — state updates, event handlers, and other components are unaffected. - When the chunk resolves: React re-attempts the render. This time the loader function returns an already-resolved module, so the real component renders. React reconciles this against the fallback that was previously committed and swaps it in — the fallback unmounts, and the real component mounts.
- On subsequent renders: the module is cached by the browser/bundler runtime, so re-rendering the same lazy component (e.g., navigating back to a route you already visited) does not re-fetch it or show the fallback again.
- On unmount: if the component’s chunk is still loading and the user navigates away, React simply discards the pending suspension — there’s no cleanup needed because nothing ever mounted.
Common Mistakes
Mistake 1: No Suspense boundary above a lazy component
import { lazy } from "react";
const Settings = lazy(() => import("./Settings.jsx"));
function App() {
return (
<div>
<Settings />
</div>
);
}
Without any Suspense ancestor, React has nowhere to render a fallback while the chunk loads, and React throws an error at runtime (“A component suspended while responding to synchronous input…”). Every lazy component must have a Suspense somewhere above it in the tree. Fixed:
import { lazy, Suspense } from "react";
const Settings = lazy(() => import("./Settings.jsx"));
function App() {
return (
<div>
<Suspense fallback={<p>Loading settings...</p>}>
<Settings />
</Suspense>
</div>
);
}
Mistake 2: Lazy-loading a named export instead of a default export
// Chart.jsx
export function Chart() {
return <div>Chart</div>;
}
// App.jsx — WRONG: the resolved module has no `default`
const Chart = lazy(() => import("./Chart.jsx"));
lazy always renders module.default. If the target file only has a named export, the resolved module object has no default property, and React throws “Element type is invalid”. Either add a default export to Chart.jsx, or remap it in the loader:
const Chart = lazy(() =>
import("./Chart.jsx").then((module) => ({ default: module.Chart }))
);
Mistake 3: Calling lazy() inside a component’s body
Defining const X = lazy(() => import("./X.jsx")) inside a component function (instead of at module scope, outside the component) creates a brand-new lazy component on every render. React then treats it as a completely different component type each time, unmounting and remounting it repeatedly instead of reusing it. Always declare lazy() calls once, at the top level of a module, exactly as shown in every example above.
Best Practices
- Reserve
lazyfor genuinely large or rarely-used code: whole routes, modals, charts, editors, admin panels — not small always-visible components. - Always declare
lazy()calls at module scope, never inside a component body or a loop/condition. - Place one
Suspenseboundary around each logically independent section so a slow chunk doesn’t stall an unrelated part of the UI. - Keep fallback UI visually close to the real content (a skeleton matching the final layout) to avoid jarring layout shifts.
- Pair
Suspensewith an error boundary — if the dynamicimport()fails (e.g., offline, or a stale deployed chunk after a redeploy), an error boundary is what catches that failure;Suspensealone does not handle rejected promises. - Combine route-based lazy loading with your router so each page is its own chunk — this is the highest-value place to apply it in most apps.
- Avoid wrapping the entire app in a single top-level
Suspenseonce you have many independent lazy sections; it forces everything to wait for the slowest chunk.
Practice Exercises
- Create two components,
Dashboard.jsxandReports.jsx, each with a default export. Build anAppthat shows a toggle button switching between them, lazy-loading each one with its ownSuspenseboundary and a distinct fallback message. - Take a component with a named export only (no default export) and write a
lazy()call that correctly loads it by remapping the resolved module in a.then(). - Build a three-route app with React Router v6 where
Home,About, andContactare all lazily loaded, wrapped in a singleSuspensearound<Routes>. Then describe, in your own words, what the user sees during the brief moment between clicking a link and the new page appearing.
Summary
React.lazy(() => import("./X.jsx"))defers fetching a component’s code until it’s about to render, splitting it into its own bundle chunk.- The module being lazy-loaded must have a default export, or you must remap a named export to
defaultin the loader. Suspenseis the boundary that renders afallbackwhile a lazy (or otherwise Suspense-aware) descendant isn’t ready yet.- Every lazy component needs a
Suspenseancestor somewhere in the tree, or React throws at runtime. - Route-based splitting (one lazy page per route) is the highest-impact, most common use case.
- Give independent UI sections their own
Suspenseboundaries so one slow chunk doesn’t block unrelated content. - Always declare
lazy()at module scope — never inside a component body, loop, or condition. - Pair Suspense with an error boundary to handle failed chunk loads gracefully.
