Good to knowReact Performance · 04

Code Splitting

lazy and Suspense load a feature’s code on demand; prefetch on hover; a transition avoids the fallback.

On this page · 7 sections
  1. In one minute
  2. The example: the size chart
  3. How it works
  4. Common mistakes
  5. Interview Q&A
  6. Related
  7. Sources

In one minute

Every import at the top of a file puts that code in the page’s initial JavaScript, which the browser must download, parse and run before the page works, even for features most users never open. Code splitting loads a feature’s code on demand. The dynamic import('./x') returns a promise for a module, and bundlers turn each one into a separate file (a chunk). lazy(() => import('./x')) makes that into a component that suspends until its code arrives, with a <Suspense> boundary showing a fallback meanwhile. Two refinements: start the download early, when the user hovers or focuses the button (prefetch), and use a transition so React keeps the current screen instead of flashing the fallback.

You’ll be able to: lazy-load a component, prefetch it on intent, and avoid the loading flash with useTransition.

Static import
ProductPage.tsx
1import SizeChart from './size-chart' // part of the page's JS23function ProductPage() {4  const [show, setShow] = useState(false)5  return (<>6    <button onClick={() => setShow(true)}>Size chart</button>7    {show && <SizeChart />}8  </>)9}
What you see
Size chart
(nothing)
Console (recorded)
size-chart.tsx evaluated
page mounted
chart shown
  1. triggerimport SizeChart from './size-chart' // part of the page's JSThe chart’s code is downloaded and run before the page even mounts, though most shoppers never open it.
  2. commit phaseThen the page appears.
  3. commit phase{show && <SizeChart />}Clicking is instant: the code is already there.

0 / 0
A product page has a “Size chart” that few shoppers open. Its code takes 500 ms to download (all four versions recorded).

The example: the size chart

Product pages have a “Size chart” button that few shoppers click. Its module logs size-chart.tsx evaluated when it runs. For the recordings, the dev server delays that file by 500 ms, like a slow network.

function PageStatic() {
	const [show, setShow] = useState(false)
	return (
		<>
			<button id="show" onClick={() => setShow(true)}>Size chart</button>
			{show && <StaticSizeChart />}
		</>
	)
}

With a normal import SizeChart from './size-chart' at the top, the recorded order, from page load to clicking the button:

size-chart.tsx evaluated
page mounted
chart shown

The chart’s code ran before the page even mounted.

1. lazy + Suspense

const loadSizeChart = () => (log('import() called'), import('./size-chart'))
const SizeChart = lazy(loadSizeChart)
function PageLazy() {
	const [show, setShow] = useState(false)
	return (
		<>
			<button id="show" onClick={() => setShow(true)}>Size chart</button>
			<Suspense fallback={fallback}>{show && <SizeChart />}</Suspense>
		</>
	)
}
page mounted
import() called
fallback shown
size-chart.tsx evaluated
chart shown

Nothing loads until SizeChart first renders. Then lazy calls the loader, the component suspends, the nearest Suspense shows “Loading size chart…”, and React retries once the module arrives. The module must default-export the component (for a named export: import('./x').then((m) => ({ default: m.SizeChart }))), and lazy must be called outside any component, or it creates a new component (and loses state) every render.

2. Prefetch on hover and focus

function PagePrefetch() {
	const [show, setShow] = useState(false)
	return (
		<>
			<button id="show" onMouseEnter={loadSizeChart} onFocus={loadSizeChart} onClick={() => setShow(true)}>
				Size chart
			</button>
			<Suspense fallback={fallback}>{show && <SizeChart />}</Suspense>
		</>
	)
}

Hover, wait a second, then click:

page mounted
import() called
size-chart.tsx evaluated
import() called
import() called
fallback shown
chart shown

The download started on hover and the module ran once, although import() was called three times (hover, focus on click, and lazy’s own first render): modules are cached by URL. But notice fallback shown: even with the code already downloaded, lazy’s first render suspended, because an import() promise never resolves synchronously. The fallback was brief, but it was there.

3. A transition

function PageTransition() {
	const [show, setShow] = useState(false)
	const [isPending, startTransition] = useTransition()
	return (
		<>
			<button id="show" onMouseEnter={loadSizeChart} onFocus={loadSizeChart} onClick={() => startTransition(() => setShow(true))}>
				Size chart {isPending && '⏳'}
			</button>
			<Suspense fallback={fallback}>{show && <SizeChart />}</Suspense>
		</>
	)
}

Clicking right away (the hover starts the download a moment before):

page mounted
import() called
import() called
pending ⏳ shown
import() called
size-chart.tsx evaluated
chart shown

No fallback at all. When an update inside startTransition suspends, React keeps showing the current UI and waits; isPending lets you show something small (here, ⏳ in the button).

Even with the code prefetched, the pending marker appeared briefly:

page mounted
import() called
size-chart.tsx evaluated
import() called
pending ⏳ shown
import() called
chart shown

To avoid that one-frame flicker of a pending indicator, libraries like spin-delay only show it if loading takes longer than a threshold (say 500 ms), and then keep it visible for a minimum time.

How it works

Common mistakes

Interview Q&A

What is code splitting?

Splitting the JavaScript bundle so each screen downloads only the code it needs, loading the rest on demand with dynamic import(). Recorded: with a static import, the size chart’s code ran before the page mounted; with lazy, only after the click.

How does React.lazy work?

lazy(load) returns a component. On its first render it calls load(); until the promise resolves it suspends, so the nearest Suspense shows its fallback. The resolved module’s default export is the component, and both are cached.

Does calling import() several times download the module several times?

No. Modules are cached by URL. Recorded: three import() calls, one size-chart.tsx evaluated.

Why might the fallback still flash after prefetching?

lazy’s first render always suspends, because an import() promise can’t resolve synchronously, even from cache. Recorded: fallback shown after a prefetch that had finished.

How do you avoid the fallback?

Make the update a transition: startTransition(() => setShow(true)). React keeps the current UI while the new one suspends, and isPending tells you it’s waiting. Recorded: no fallback, just the ⏳ marker.

Sources

Primary source: react.dev/reference/react/lazy