Topic 16.8
Code Splitting with lazy
In one line
Code splitting cuts your JavaScript into smaller files (chunks) that load only when needed: lazy(() => import("./Chart")) makes a component load on first render, <Suspense> shows a fallback meanwhile, and splitting by route plus preloading on hover keeps the first page fast without making later clicks slow.
Think of it like this
Packing for a trip. You don't carry your whole wardrobe to the airport. You pack today's clothes in a small bag and have the winter coat sent only if you go to the mountains. An app that ships all its code up front is carrying the whole wardrobe: the first screen waits for code it may never use.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Bundle
- The JavaScript files a bundler produces from all your modules.
- Bundler
- A build tool (Vite, webpack, Turbopack) that combines and optimises your modules for the browser.
- Code splitting
- Breaking the bundle into smaller files that load only when they're needed.
- Chunk
- One of those smaller files. A dynamic import creates one.
- Dynamic import
import("./file")used like a function: it loads a module later and returns a promise.- lazy
- A React function that turns a dynamic import into a component that loads on first render.
- Fallback
- What a Suspense boundary shows while something inside it is still loading.
- Preloading
- Starting to download code before the user needs it, so it's ready on click.
Step by step
01Find what's heavy
Build the app and read the output. Vite prints each file with its size and gzip size, and warns about chunks over 500 kB. A visualiser plugin (for example rollup-plugin-visualizer) shows which libraries take the space. The file names and sizes below are an example.
$ npm run build
✓ 412 modules transformed.
dist/index.html 0.46 kB │ gzip: 0.30 kB
dist/assets/index-B1x9.css 4.12 kB │ gzip: 1.40 kB
dist/assets/index-Dk2q.js 612.80 kB │ gzip: 189.22 kB
(!) Some chunks are larger than 500 kB after minification.02Split a heavy component with lazy
Replace the static import with lazy at the top level of the module, and put a Suspense boundary where it renders.
import { lazy, Suspense } from "react";
// import Chart from "./Chart.jsx"; // before: always in the main bundle
const Chart = lazy(() => import("./Chart.jsx")); // after: its own chunk
export default function Dashboard() {
return (
<Suspense fallback={<p>Loading chart...</p>}>
<Chart />
</Suspense>
);
}03Split by route
With React Router data routers, give a route a lazy function that returns its module. The route's component, loader and action download only when someone navigates there.
const router = createBrowserRouter([
{ path: "/", Component: Home },
{
path: "/reports",
lazy: async () => {
const { Reports, reportsLoader } = await import("./routes/reports.jsx");
return { Component: Reports, loader: reportsLoader };
},
},
]);04Build again and compare
The main chunk shrinks and new chunks appear. The first page downloads only the main chunk.
$ npm run build
dist/assets/index-C7aa.js 198.40 kB │ gzip: 64.10 kB
dist/assets/Chart-Bq31.js 391.75 kB │ gzip: 118.02 kB
dist/assets/reports-x8Fd.js 22.60 kB │ gzip: 7.85 kB05Preload on intent
Start the download on hover or focus, and show the component inside startTransition so a still-loading chunk keeps the old screen instead of flashing a fallback.
Try it yourself
- 1
Make the download fail
In the first example, change
resolve(...)toreject(new Error("Chunk failed"))(and the Promise callback to(resolve, reject) =>). Predict what the page and console show with no error boundary. Then think about where an error boundary with a "Reload" button belongs. - 2
Check the fallback flash
In the preload example, change the click handler to
() => setOpen(true)and log the area at 30 ms and 400 ms after the click. Compare with the transition version. - 3
Preload when idle
Instead of hover, call
loadSettings()fromsetTimeout(loadSettings, 200)after mount (orrequestIdleCallbackin a real app). When would that be better than preloading on hover?
Code & diagrams
The loader runs on the first render of Chart, not when the page loads. Once the module has arrived, lazy keeps it, so showing the chart again is instant.
Preview
Console
Nothing logged yet.Expected console output
Page loaded: nothing downloaded yet
Downloading the Chart chunk...
Area: Loading chart...
Chunk arrived
Area: 📈 Sales chart
Second time, no download: 📈 Sales chartThe download starts on hover, so by the click the chunk is ready and the panel appears with no fallback at all. Try it without `startTransition` (plain `setOpen(true)`): the fallback flashes for about 300 ms even though the code is already loaded, because React reveals content that was behind a shown fallback on a short throttle.
Preview
Console
Nothing logged yet.Expected console output
--- pointer enters the button
Download started
--- click
Area shows: ⚙️ Settings panelBreak it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
lazy inside a component
Move const Chart = lazy(fakeImport); inside App, show the chart, then click anything that re-renders App.
Myth vs fact
Myth
Splitting everything into tiny chunks is best.
Fact
Each chunk is another request with its own overhead, and many tiny chunks can create a request waterfall. Split by route and by genuinely heavy, optional features.
Myth
lazy works with named exports directly.
Fact
lazy expects a promise of a module with a default export. For named exports, map it: .then((m) => ({ default: m.Chart })).
Myth
Code splitting makes the app faster everywhere.
Fact
It makes the first load faster but can make the first use of a split feature slower. Preloading closes that gap.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
React 19 reveals Suspense content on a short throttle (about 300 ms) once a fallback has been shown, to batch reveals and avoid flicker. Wrapping the update in a transition avoids showing the fallback at all when the content can be ready quickly.
- ▸
On the server,
lazyworks with streaming SSR: the chunk is loaded on the server, its HTML streamed, and the client must download the chunk before hydrating that part. Frameworks inject<link rel="modulepreload">for chunks the page needs, so they download in parallel instead of after the main bundle. - ▸
Vite emits
modulepreloadfor a chunk's own dependencies automatically; for your own preloads you can call the import early, or add<link rel="modulepreload" href=...>hints. React 19'spreloadModulefromreact-domcan also emit such hints. - ▸
Deployments break lazy loading for users who still have the old app open: their old main bundle asks for chunk filenames that no longer exist. Keep old assets for a while after deploying, or catch chunk load errors in an error boundary and reload.
Remember this
- 1
Why bundle size matters. A bundler (Vite, webpack, Turbopack) combines your modules into a few JavaScript files called a bundle. The browser must download, parse and execute that JavaScript before React can render. On a mid-range phone, parsing and running 1 MB of JavaScript can take well over a second, and it blocks the main thread. Shipping less code for the first page is one of the biggest wins for load time.
- 2
Code splitting means the bundler puts some modules into separate files, called chunks, loaded on demand. The trigger is a dynamic import:
import("./Chart.jsx")is a function-like expression that returns a promise for the module (see /javascript modules). Any bundler that sees it creates a separate chunk for that module and its dependencies. - 3
**
lazy** turns a dynamic import into a component:const Chart = lazy(() => import("./Chart.jsx")). The module must have a default export that is a component (or you map it:lazy(() => import("./x").then((m) => ({ default: m.Chart })))). Declare lazy components at the top level of a module, never inside a component: a newlazyeach render is a new type and would reload and reset (Topic 16.2). The first time<Chart />renders, React calls the loader once, and the component suspends until the chunk arrives. After that the module is cached and renders instantly. - 4
Suspense shows the waiting state. A suspended component needs a
<Suspense fallback={...}>above it (Topic 14.3). The fallback replaces the boundary's content while the chunk loads. Place boundaries where a loading state makes sense: around a panel, not around the whole app. If the chunk fails to load (offline, a new deployment removed old files), the promise rejects and the nearest error boundary (Topic 14.5) shows; a common fix is offering a reload. - 5
Route-level splitting is the best default: each page becomes its own chunk, because users visit one page at a time. Frameworks do it automatically: Next.js splits per route, and React Router's
lazyroute property loads a route's module (component, loader, action) on navigation (Topic 13.6). Component-level splitting is for heavy parts that aren't always shown: a chart library, a rich text editor, a map, a modal used rarely. - 6
Preloading removes the wait. Start the import before the user needs it: when they hover or focus a link or button, when the browser is idle, or right after the first page loads. Keep the import promise in a variable so it starts at most once (bundlers also cache modules). Combine it with **
startTransition** when showing the lazy component: if the chunk is still loading, React keeps the current screen instead of flashing a fallback, and if it's already loaded, it appears with no fallback at all.
Explain it without notes
What is code splitting and why does it improve load time?
How do lazy and Suspense work together?
Why is route-level splitting a good default?
How can you avoid a loading delay when the user opens a lazily loaded feature?
Practice
Turn import MarkdownEditor from "./MarkdownEditor" into a lazily loaded component with a fallback.
The module ./Map.jsx exports export function MapView() {...} (no default). Load it lazily.
Trade-offs
- ↔
Splitting makes the first load faster but adds requests and a possible wait on first use; preloading trades some bandwidth for a faster later click.
- ↔
Fine-grained splitting can create waterfalls (a chunk that, once loaded, imports another chunk). Keep heavy dependencies together with the feature that uses them.
Done when you can
I can explain why bundle size affects load time.
I can lazy-load a component with a Suspense fallback.
I can split routes in React Router or rely on Next.js route splitting.
I can preload a chunk on hover and reveal it in a transition.
I can handle a failed chunk load with an error boundary.