React – Code Splitting with lazy and Suspense

August 8, 20266 min readUpdated 8/18/2026

Everything imported at the top of your application ends up in the bundle every visitor downloads — including the screens most of them will never open. lazy and Suspense are how you stop that.

The problem, measured

The pizza app has six admin screens: reports with charts, and CRUD pages for products, toppings, crusts, orders and users. They are reachable only by an administrator. In a shop, that is a handful of people out of every thousand visitors.

Imported normally, all of it — plus the charting library, plus Redux and its four slices — lands in the bundle that every customer downloads before they can look at a pizza.

lazy

/*
 * REACT CONCEPT: lazy + Suspense (code splitting)
 *
 * React.lazy turns this import into a separate bundle that is fetched only when the route is
 * first visited. Customers — the overwhelming majority of visitors — never open /admin, so its
 * code should not be part of the JavaScript everyone downloads on the home page.
 *
 * Because the import is asynchronous, React needs something to show while it is in flight: that
 * is what the <Suspense fallback> below provides.
 *
 * The lazily-imported module must have a DEFAULT export.
 */
const AdminLayout = lazy(() => import('./pages/admin/AdminLayout'));
const AdminReportsPage = lazy(() => import('./pages/admin/AdminReportsPage'));
const AdminProductsPage = lazy(() => import('./pages/admin/AdminProductsPage'));
const AdminToppingsPage = lazy(() => import('./pages/admin/AdminToppingsPage'));
const AdminCrustsPage = lazy(() => import('./pages/admin/AdminCrustsPage'));
const AdminOrdersPage = lazy(() => import('./pages/admin/AdminOrdersPage'));
const AdminUsersPage = lazy(() => import('./pages/admin/AdminUsersPage'));

lazy takes a function that returns a dynamic import(). That import is a promise, and the bundler treats it as a split point: everything reachable from it goes into a separate file, fetched the first time the component renders.

Two rules that will bite you:

The module must have a default export. This project uses named exports everywhere else; the admin pages are the deliberate exception:

// Every lazily-loaded page ends with this, and it is the reason why.
export default function AdminLayout() { /* … */ }

If you would rather keep named exports, adapt in the import:

const AdminLayout = lazy(() =>
  import('./pages/admin/AdminLayout').then((m) => ({ default: m.AdminLayout })),
);

Declare it at module scope, never inside a component. A lazy() call inside a component body creates a new lazy component on every render, so React unmounts and refetches the whole subtree each time. Same class of bug as declaring a component inside another component.

Suspense

A lazy component cannot render until its code arrives, so React needs something to show meanwhile. That is Suspense:

<main className="flex-grow-1">
  {/* Any render error inside a route is caught here rather than blanking the whole app. */}
  <ErrorBoundary>
    <Suspense
      fallback={
        <div className="text-center py-5">
          <Spinner animation="border" variant="danger" role="status">
            <span className="visually-hidden">Loading…</span>
          </Spinner>
        </div>
      }
    >
      <Routes>{/* … */}</Routes>
    </Suspense>
  </ErrorBoundary>
</main>

One boundary covers all seven lazy components, because they are all inside it. You do not need one per lazy import — you need one wherever a distinct piece of loading UI makes sense.

Suspense goes above the lazy component, not around it in the same render. A component cannot suspend and catch its own suspension.

Pair it with an error boundary

Suspense handles the pending case. It does not handle failure — and a chunk fetch genuinely does fail, most often when you deploy while someone has the old page open and the hashed filename no longer exists. Without a boundary, that is a blank page.

Note the ordering above: ErrorBoundary is outside Suspense. That way a failed chunk load renders the error fallback. Error Boundaries has the details.

What it produced

Here is the real build output, unedited:

dist/index.html                              0.88 kB │ gzip:   0.42 kB
dist/assets/index--mWIANA6.css             233.62 kB │ gzip:  32.02 kB
dist/assets/useMergedRefs-BY2lr20j.js        0.51 kB │ gzip:   0.32 kB
dist/assets/rolldown-runtime-hePW80VL.js     0.71 kB │ gzip:   0.42 kB
dist/assets/AdminLayout-BtuxqFaS.js          0.86 kB │ gzip:   0.44 kB
dist/assets/AdminOrdersPage-DUtzGoIo.js      3.16 kB │ gzip:   1.35 kB
dist/assets/AdminUsersPage-BlgOdDXt.js       3.47 kB │ gzip:   1.46 kB
dist/assets/AdminCrustsPage-BlAwkw38.js      4.50 kB │ gzip:   1.74 kB
dist/assets/AdminToppingsPage-C_l3qJy0.js    4.55 kB │ gzip:   1.77 kB
dist/assets/AdminProductsPage-Cn3KqQMA.js    6.24 kB │ gzip:   2.22 kB
dist/assets/ToastContext-BSYXrgfZ.js         9.53 kB │ gzip:   3.46 kB
dist/assets/api-CJNWQurO.js                 14.12 kB │ gzip:   5.33 kB
dist/assets/Table-YEv3Zq6I.js               17.56 kB │ gzip:   6.16 kB
dist/assets/store-z0RVAOPD.js               36.54 kB │ gzip:  12.84 kB
dist/assets/index-B9HlveUY.js              350.46 kB │ gzip: 109.21 kB
dist/assets/AdminReportsPage-b0f8Qhta.js   353.73 kB │ gzip:  98.69 kB

Read that as two groups.

index-B9HlveUY.js, 350 kB / 109 kB gzipped, is what a customer downloads. React, React DOM, the router, react-bootstrap, the four contexts, and every customer page.

Everything else is admin, and none of it is fetched until someone opens /admin. Adding it up: 354 kB of reports (almost entirely the charting library), 37 kB of Redux and its slices, 18 kB of Bootstrap's table, and 23 kB across the five CRUD pages — roughly 430 kB, or 122 kB gzipped, that a customer never downloads. Statically imported, the entry bundle would have been more than twice its current size.

Two of those lines are worth a second look.

store-z0RVAOPD.js exists because <Provider store={store}> is mounted inside AdminLayout rather than in main.tsx. Redux is only reachable from a lazy route, so the bundler puts it in a lazy chunk. Had the provider gone at the root, all 37 kB would be in the entry bundle for everyone. Where you mount a provider is a bundling decision as well as an architectural one.

Table-YEv3Zq6I.js and api-CJNWQurO.js are shared chunks — code used by more than one lazy page, factored out so it is downloaded once rather than duplicated into each. You do not configure this; the bundler works it out.

Where to split

Routes, and specifically routes a given visitor is unlikely to visit. That is where the cost/benefit is clearest and where the loading state is least intrusive — a brief spinner during navigation is something users already expect.

Good candidates beyond routes:

  • A heavy library used on one screen — charts, a rich text editor, a PDF viewer, a map.
  • A modal or drawer whose contents are large and rarely opened.
  • Anything behind a permission most users do not have.

Do not split things that are needed immediately. Splitting the home page adds a network round trip to the critical path and makes the first load slower. And splitting many small components produces a swarm of tiny requests, which is worse than one file — modern bundlers already handle this well when you split at meaningful boundaries.

Preloading

The one downside of route splitting is a pause on first navigation. You can hide it by starting the fetch on hover or focus, before the click:

const preloadAdmin = () => { void import('./pages/admin/AdminLayout'); };

<Nav.Link as={NavLink} to="/admin" onMouseEnter={preloadAdmin} onFocus={preloadAdmin}>
  Admin
</Nav.Link>

The module registry deduplicates, so calling import() twice fetches once. By the time the click lands, the chunk is usually already there.

Suspense is bigger than lazy

Everything above uses Suspense for code loading, which is what it originally shipped for. It is a general mechanism for "this subtree is not ready yet", and frameworks now use it for data too — a component suspends while its data loads, and the nearest boundary shows the fallback.

In a plain Vite + React app like this one, you will mostly meet it as the partner to lazy. Reaching for suspense-based data fetching means a framework or a library that supports it.

Next

Styling.