Command Palette

Search for a command to run...

Hectal
PHASE 16Advanced ~22 min· topic 8 of 10

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.

terminalwhole filebash
$ 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.

Dashboard.jsxwhole filejsx
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.

router.jsxwhole filejsx
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.

terminalwhole filebash
$ 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 kB
Build again and comparediagram
Rendering diagram…

05Preload 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. 1

    Make the download fail

    In the first example, change resolve(...) to reject(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. 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. 3

    Preload when idle

    Instead of hover, call loadSettings() from setTimeout(loadSettings, 200) after mount (or requestIdleCallback in a real app). When would that be better than preloading on hover?

Code & diagrams

lazy + Suspense: download on first use New tab

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

Press Run to see the page.

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 chart
Preload on hover, reveal in a transition New tab

The 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

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

--- pointer enters the button
Download started
--- click
Area shows: ⚙️ Settings panel

Break 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.

terminal
$ Console
── what you'll see ──
Downloading the Chart chunk...
Chunk arrived
Downloading the Chart chunk...
(the fallback appears again on every App render)

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, lazy works 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 modulepreload for 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's preloadModule from react-dom can 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. 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. 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. 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 new lazy each 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. 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. 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 lazy route 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. 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

01

What is code splitting and why does it improve load time?

02

How do lazy and Suspense work together?

03

Why is route-level splitting a good default?

04

How can you avoid a loading delay when the user opens a lazily loaded feature?

Practice

01

Turn import MarkdownEditor from "./MarkdownEditor" into a lazily loaded component with a fallback.

02

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.