Quick Answer

Code splitting breaks your JavaScript bundle into smaller chunks that load on demand instead of all at once. In React you do it with React.lazy(() => import('./Chart')) wrapped in a <Suspense> boundary that shows a fallback while the chunk downloads. The bundler automatically emits each dynamically imported module as a separate file.

Why split the bundle

A React app built without splitting produces one JavaScript file with everything in it: every route, every modal, every chart library, every date picker. The browser must download and parse all of it before the app starts - even if the visitor only ever opens the home page.

Code splitting divides that bundle along import() boundaries. The checkout page's code loads when someone navigates to checkout. The rich-text editor loads when someone opens it. The initial download shrinks to what the first screen actually needs.

The mechanism is the dynamic import() expression, which returns a promise for a module:

// static import - bundled into the current chunk
import { Chart } from './Chart';

// dynamic import - its own chunk, fetched on demand
const { Chart } = await import('./Chart');

webpack, Rollup (which Vite uses), and Parcel all treat a dynamic import() as a split point automatically. You do not configure anything - you just use the syntax.

React.lazy and Suspense

React components cannot be loaded with a bare await import() in the render path, because rendering is synchronous. React.lazy bridges the gap:

import { lazy, Suspense } from 'react';

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

function Dashboard() {
  return (
    <Suspense fallback={<p>Loading chart...</p>}>
      <HeavyChart />
    </Suspense>
  );
}

lazy takes a function that returns import('./HeavyChart'). The module's default export must be a React component. It returns a component you render like any other.

"Suspending" means a component threw a promise instead of returning UI, signalling that it is not ready. React catches that at the nearest <Suspense> boundary and renders the fallback until the promise resolves. Once lazy has loaded a chunk it caches the result, so the fallback shows only on the first render, not on every mount afterwards.

The <Suspense> boundary is required. Without a Suspense ancestor there is no fallback to show while the component is pending - during server rendering or a synchronous update this raises an error such as "A component suspended while responding to synchronous input." One boundary can wrap several lazy components, and they share a single fallback.

Route-based splitting: the highest-value place

The single highest-value split is by route. Each page becomes its own chunk, so visiting / never downloads the code for /settings.

import { lazy, Suspense } from 'react';
import { Routes, Route } from 'react-router-dom';

const Home = lazy(() => import('./pages/Home'));
const Settings = lazy(() => import('./pages/Settings'));

<Suspense fallback={<PageSkeleton />}>
  <Routes>
    <Route path="/" element={<Home />} />
    <Route path="/settings" element={<Settings />} />
  </Routes>
</Suspense>

Make the fallback a skeleton that matches the page layout, not a centered spinner. A spinner that appears for 200ms on every navigation reads as jank; a skeleton in roughly the right shape reads as the page loading. You can also nest boundaries - a coarse one at the route level and finer ones around slow widgets inside the page - so a slow chart does not hold up the rest of the route.

Frameworks handle route splitting for you. Next.js splits per route automatically - every page and layout is its own chunk with no lazy calls. React Router's data APIs and TanStack Router load route modules on navigation. If you use a framework router, route-level splitting is already done, and your job is splitting within a route: the heavy dependency that only one tab or one modal needs.

What the bundler actually does

Concretely, here is what a build produces. Take a small app with one dynamically imported component:

const HeavyChart = lazy(() => import('./HeavyChart.jsx'));

Running vite build emits two files instead of one:

dist/assets/index-CefhWMdB.js       222 kB   # main bundle (69 kB gzipped)
dist/assets/HeavyChart-fIBeiiYZ.js  0.3 kB   # the split chunk

HeavyChart and everything it imports land in a separate file with a hashed name, fetched only when <HeavyChart /> first renders. The hash means the browser caches that file until that specific code changes.

The browser requests the chunk the moment React first tries to render <HeavyChart /> - so there is a round trip between the fallback appearing and the component showing. That is the cost you are trading for a smaller initial load, and it is why preloading (covered below) matters.

Notice the main bundle is still 222 kB - React, ReactDOM, the router, and shared code all stay there. Splitting one small component off a large app is a minor win. The strategy pays off when you split the big, rarely-used dependencies: charting libraries, WYSIWYG editors, PDF viewers, map SDKs. Check what is actually in your bundle with rollup-plugin-visualizer or webpack-bundle-analyzer before guessing which imports are worth splitting.

The mistakes that break it

Calling lazy() inside a component. This looks harmless and is the most damaging mistake:

function Dashboard() {
  // WRONG: a new component identity on every render
  const Chart = lazy(() => import('./Chart'));
  return <Suspense fallback={<p>...</p>}><Chart /></Suspense>;
}

Every render creates a brand-new lazy component, so React unmounts the old one, shows the fallback again, and re-runs the import factory. In a test, rendering this three times called the import function three times; the same code hoisted to module scope called it once. Always declare lazy components at the top level of the module.

Named exports. React.lazy only understands a default export. If your component is a named export, map it yourself:

const ChartLegend = lazy(() =>
  import('./Chart').then((m) => ({ default: m.ChartLegend }))
);

Skip the mapping and React throws "Element type is invalid. Received a promise that resolves to: undefined. Lazy element type must resolve to a class or function."

No error boundary. If the chunk fails to download - a flaky network, or a deploy that changed the hashes while the user's tab was open - the import() promise rejects and Suspense does not catch it. Wrap lazy content in an error boundary that offers a retry, or that section simply vanishes.

Beyond React.lazy

React.lazy is the primitive. Real apps usually layer more on top:

  • Preloading. A chunk that loads only when rendered means a spinner on every navigation. Kick off the import() early - on link hover, or once the initial page settles - so the code is cached before the user clicks. Next.js prefetches route chunks for links in the viewport automatically.
  • Framework loaders. Next.js next/dynamic wraps React.lazy with an SSR toggle (ssr: false skips server-rendering a browser-only component) and a built-in loading prop.
  • Chunk grouping. Splitting too finely means many tiny requests, each with its own latency. Bundlers let you hint that several dynamic imports should land in one chunk.

The goal is not the maximum number of chunks. It is the smallest useful initial download, with everything else arriving before it is needed.

Frequently Asked Questions

What is the difference between code splitting and lazy loading? Code splitting is the build-time act of breaking the bundle into separate chunks. Lazy loading is the runtime act of fetching a chunk only when it is needed. React.lazy with a dynamic import does both.
Do I need Suspense to use React.lazy? Yes. React needs a Suspense boundary above the lazy component to render a fallback while the chunk loads. Without one, server rendering and synchronous updates error out.
Why does my fallback flash on every re-render? You are almost certainly calling React.lazy inside a component body, which creates a new lazy component on each render. Move the lazy() call out to module scope.
How do I lazy-load a component that is a named export? Chain .then() on the import and return an object whose default is your component: import('./X').then(m => ({ default: m.Thing })). React.lazy only reads the default export.
Does Next.js need manual code splitting? Not for routes - every page and layout is a separate chunk automatically. You still split heavy components within a page yourself, using next/dynamic or React.lazy.