Skip to content
React rc performance 4 min read

Code Splitting & lazy

Every kilobyte of JavaScript you ship has to be downloaded, parsed, and executed before your users can interact with the page. As an app grows, bundling everything into one file means a user visiting the login screen also pays for the dashboard, the settings panel, and the rarely-used admin tools. Code splitting fixes this by breaking the bundle into smaller chunks that load on demand, and React makes the technique first-class through React.lazy and Suspense.

How code splitting works

Under the hood, code splitting relies on the dynamic import() syntax. A static import is resolved at build time and folded into the main bundle; a dynamic import() returns a Promise and tells the bundler (Vite, webpack, Rollup) to emit that module as a separate chunk. The chunk is fetched over the network only when the import() call actually runs.

// Static import — bundled into the main chunk eagerly.
import { formatDate } from './utils';

// Dynamic import — its own chunk, fetched only when this line executes.
const heavy = await import('./heavyChart');
heavy.render();

You rarely call import() by hand for components, though. React wraps this pattern in React.lazy.

React.lazy and Suspense

React.lazy takes a function that returns a dynamic import() and gives you back a component you can render like any other. Because the module loads asynchronously, you must render the lazy component inside a <Suspense> boundary that supplies a fallback to show while the chunk is in flight.

import { lazy, Suspense } from 'react';

// SettingsPanel and its dependencies become a separate chunk.
const SettingsPanel = lazy(() => import('./SettingsPanel'));

function App() {
  const [showSettings, setShowSettings] = useState(false);

  return (
    <div>
      <button onClick={() => setShowSettings(true)}>Open settings</button>
      {showSettings && (
        <Suspense fallback={<p>Loading settings…</p>}>
          <SettingsPanel />
        </Suspense>
      )}
    </div>
  );
}

The SettingsPanel chunk is not downloaded until the user clicks the button. Note that React.lazy only works with default exports — the module’s default must be a React component. If you use named exports, re-map them inside the import.

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

Tip: Place Suspense boundaries thoughtfully. One boundary high in the tree means a single fallback for a large region; several smaller boundaries let independent sections stream in separately and keep the UI responsive.

Route-based splitting

The highest-impact place to split is at route boundaries — a user almost never needs every page at once. With a router like React Router, lazy-load each route’s component so navigating to a page triggers its chunk download.

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

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

export default function App() {
  return (
    <BrowserRouter>
      <Suspense fallback={<div className="spinner" />}>
        <Routes>
          <Route path="/" element={<Home />} />
          <Route path="/dashboard" element={<Dashboard />} />
          <Route path="/admin" element={<Admin />} />
        </Routes>
      </Suspense>
    </BrowserRouter>
  );
}

The initial bundle now contains only the shell and the first route’s code. The Admin chunk — often the heaviest and least-visited — only ships to the handful of users who actually open it.

Component-based splitting

Route splitting handles the coarse structure, but individual components can also be heavy: a rich text editor, a charting library, a map, or a modal that most users never open. Defer these the same way.

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

function CommentBox() {
  const [editing, setEditing] = useState(false);

  return editing ? (
    <Suspense fallback={<textarea disabled placeholder="Loading editor…" />}>
      <RichTextEditor />
    </Suspense>
  ) : (
    <button onClick={() => setEditing(true)}>Write a comment</button>
  );
}

You can warm the chunk before it is needed by calling the import on hover or focus, so the code is already cached by the time the user clicks:

<button
  onMouseEnter={() => import('./RichTextEditor')}
  onClick={() => setEditing(true)}
>
  Write a comment
</button>

Measuring bundle impact

Splitting blindly can backfire — too many tiny chunks add request overhead. Measure before and after. Vite uses Rollup, so you can visualize chunk composition with rollup-plugin-visualizer.

npm install -D rollup-plugin-visualizer
npx vite build

Output:

dist/assets/index-a1b2c3.js        48.2 kB │ gzip: 16.1 kB
dist/assets/Dashboard-d4e5f6.js    91.7 kB │ gzip: 28.4 kB
dist/assets/Admin-g7h8i9.js       142.3 kB │ gzip: 41.0 kB
✓ built in 1.84s

Each lazy boundary shows up as its own line, making it easy to spot which routes are bloated and worth splitting further. The table below summarizes the common strategies.

StrategyGranularityTriggered byBest for
Route-basedPer pageNavigationThe default, highest-ROI split
Component-basedPer featureUser interactionHeavy widgets, modals, editors
Vendor splittingPer libraryBuild configCaching stable third-party code
Eager preloadingPer chunkHover/idleHiding latency before a click

Best Practices

  • Split at route boundaries first — it gives the biggest payload reduction for the least effort.
  • Always quote a lazy component inside a <Suspense> boundary with a meaningful fallback that matches the eventual layout to avoid layout shift.
  • Remember React.lazy requires a default export; re-map named exports inside the import().
  • Preload likely-next chunks on hover, focus, or during idle time to hide network latency.
  • Avoid over-splitting — many tiny chunks add request overhead and can slow things down.
  • Measure with a bundle visualizer before and after; let real chunk sizes guide where to split.
  • Keep shared dependencies in a stable vendor chunk so browser caching survives app deploys.
Last updated June 14, 2026
Was this helpful?