Ch. 3 · React

React lazy Loading and Code Splitting

Split React bundles with lazy and Suspense, preload chunks before the click, and handle the chunk-load failures that follow every deploy.

~6 min readintermediateupdated Oct 19, 2026

A React app’s first load usually ships far more JavaScript than the first screen needs. The admin settings page, the rich text editor, the charting library behind the analytics tab: all of it is in the main bundle because something imports it somewhere. Code splitting moves those pieces into separate files that load only when needed, and in React the standard tool is lazy combined with <Suspense>.

It’s a short API, but interview follow-ups go beyond the syntax: where to split, how to avoid a loading flash on every click, what happens when a chunk fails, and the mistake that silently resets component state.

What lazy and the bundler each do

A static import Chart from './Chart' tells the bundler the code is always needed. A dynamic import('./Chart') returns a promise and tells Vite, webpack, esbuild or Rollup to emit Chart and everything only it depends on as a separate chunk, fetched when that import() runs.

lazy turns that promise into something you can render:

import { lazy, Suspense } from 'react';

const RevenueChart = lazy(() => import('./RevenueChart'));

export function AnalyticsTab() {
  return (
    <Suspense fallback={<ChartSkeleton />}>
      <RevenueChart />
    </Suspense>
  );
}
JSX

The first time React renders <RevenueChart />, it calls the loader. While the promise is pending, the component suspends and React shows the nearest <Suspense> fallback. When the promise resolves, React renders the module’s default export. Every later render reuses the cached result, so the loader runs once per lazy() call.

Running a version of this with React 19 in jsdom, where the fake import takes 50 ms, the container shows Loading chart… immediately and chart loaded once the promise resolves. That’s the whole mechanism.

The default export requirement

lazy expects the promise to resolve to a module object with a default property. If RevenueChart.jsx only has a named export, React logs lazy: Expected the result of a dynamic import() call. Instead received: ... and then throws Element type is invalid. Received a promise that resolves to: undefined. Lazy element type must resolve to a class or function. Map the named export yourself:

const RevenueChart = lazy(() =>
  import('./charts').then((m) => ({ default: m.RevenueChart })),
);
JSX

Be aware that importing from a barrel file like ./charts/index.js may pull every chart in that barrel into the same chunk.

Where to split

The highest-value splits are usually routes. A user on /orders doesn’t need the code for /settings/billing. Router libraries support this directly (React Router’s lazy route option, Next.js and other frameworks split per route automatically), and with a hand-rolled setup you wrap each route element in lazy.

Below the route level, split components that are heavy and not always used: a markdown editor that opens only when the user clicks “Edit”, a PDF viewer, a map, a chart library on a tab most people never visit. A 4 KB date picker isn’t worth a separate request; the extra round trip costs more than the bytes saved.

Check the result in the bundler output. Vite and webpack print each chunk’s size, and a bundle analyser (rollup-plugin-visualizer, webpack-bundle-analyzer) shows whether the heavy library actually moved. If something else imports RevenueChart statically, the chunk still exists but the code is also in the main bundle, and you’ve gained nothing.

Placing Suspense boundaries

Where you put <Suspense> decides what disappears while the chunk loads. Put one around the whole app and a click on the analytics tab replaces the entire screen, header included, with a spinner. Put it right around the lazy component and only that region shows a skeleton.

Two refinements. First, if route changes are wrapped in a transition (startTransition, which most routers do for you), React keeps the current page on screen while the next route’s chunk loads instead of flashing the fallback. Second, make fallbacks the same size as the content they stand in for, so the page doesn’t jump when the chunk arrives.

The mistake that resets state

This looks harmless and is a real bug:

function Dashboard({ widget }) {
  const Widget = lazy(() => import(`./widgets/${widget}.jsx`)); // inside render
  return <Suspense fallback={<Spinner />}><Widget /></Suspense>;
}
JSX

Every render of Dashboard calls lazy() again and gets a new component type. React compares element types to decide whether to keep a component; a new type means unmount the old one and mount a fresh one. In a jsdom test with this pattern, an inner counter clicked up to 2 showed the fallback again as soon as the parent re-rendered for an unrelated reason, then came back reading 0. Any form input, scroll position or local state inside is lost on each parent update.

Declare lazy components at module level. If the choice is dynamic, build a map once:

const widgets = {
  revenue: lazy(() => import('./widgets/revenue.jsx')),
  churn: lazy(() => import('./widgets/churn.jsx')),
};

function Dashboard({ widget }) {
  const Widget = widgets[widget];
  return <Suspense fallback={<Spinner />}><Widget /></Suspense>;
}
JSX

Making the split invisible with preloading

A lazy route that starts downloading on click always shows a loading state for one round trip. You can start earlier. Because the bundler caches modules, calling the same import() twice fetches once, so keep the loader in a variable and call it when intent is likely:

const loadEditor = () => import('./MarkdownEditor');
const MarkdownEditor = lazy(loadEditor);

<button onMouseEnter={loadEditor} onFocus={loadEditor} onClick={() => setEditing(true)}>
  Edit description
</button>
JSX

Hover and focus usually come 100 to 300 ms before a click, often enough for a small chunk to arrive. For likely next steps (the checkout page from the cart), preload in a requestIdleCallback after the current page has settled.

When the chunk fails to load

Every deploy creates this failure. A user opens the app at 9:00, you deploy at 9:30 with new hashed filenames, old files are removed, and at 9:45 the user clicks the analytics tab. The browser requests RevenueChart-a1b2c3.js, gets a 404, and the import rejects with something like Failed to fetch dynamically imported module (the wording varies by browser). The lazy component throws that error during render, so only an error boundary can catch it.

There’s a catch that surprises people. lazy caches a rejection just as it caches success. In a jsdom test, a loader that failed once and would have succeeded the second time was never called again: resetting the error boundary just rendered the same error. A “Try again” button that only resets the boundary does nothing.

Practical options, from simplest:

  • Show a boundary message with a “Reload page” button. A full reload fetches the new index.html and the new chunk names, which is the only real fix for a removed file.
  • Keep previous deploys’ assets available for a while (many hosts do this by default), so old tabs keep working.
  • For transient network failures, retry inside the loader before giving up: lazy(() => retry(() => import('./RevenueChart'), 2)), where retry re-calls the import after a short delay.

Interview exercise: the slow admin panel

“Our app’s main bundle is 1.8 MB. The admin panel, used by 2% of users, includes a rich text editor and a charting library. Users report a white screen for a second when they first open the admin panel after the split you proposed. What did you do, and how do you fix the white screen?”

Answer. I’d have split the admin routes with lazy so the editor and chart library leave the main bundle, which helps the 98% who never open the panel. The white screen means the <Suspense> boundary is too high, probably around the whole app, so the fallback replaces everything, or the fallback is empty. I’d move a boundary inside the layout so the header and menu stay visible with a skeleton in the content area, make sure route changes run in a transition so the current page stays up while the chunk loads, and preload the admin chunk when an admin hovers or focuses the admin link. I’d also add an error boundary around the admin routes with a reload button, because the first click after a deploy can fail to fetch a removed chunk, and a lazy component that failed once stays failed until the page reloads.

Further reading

More in React

read ✓React · hard

React Context Splitting for Performance

Why every React context consumer re-renders when any part of the value changes, and how splitting state, actions and slow-changing data fixes it.

~6 min readread →
read ✓React · mid

React Deferred Results and Stale UI

Keep a search input responsive with useDeferredValue, show users when results are stale, and see why memo is required and fetches aren't reduced.

~6 min readread →
read ✓React · mid

React Context Scope and Update Costs

Which components re-render when a React context value changes, why an inline value object re-renders every consumer, and how to scope providers.

~6 min readread →
esc