skip to content

Code Splitting with lazy and Suspense

React.lazy turns a dynamic import into a component and Suspense supplies its fallback, letting a route or a heavy modal ship in its own chunk. Interviewers want the whole story: where the boundary goes, what the user sees while it loads, and what happens when a chunk fails.

part ofReactoverview, primer and where to startread it →
on this pageshow

explore

questions

4

In React 19, how does React.lazy let a route component ship in its own chunk — what must the imported module expose, and what must be present in the tree when the lazy component first renders?

level: juniorimportance: must knowfreq 70%

answer

  1. two APIs, one boundary
  2. the import call must be dynamic
  3. the module shape React expects
  4. default export, or reshape it
  5. nearest ancestor supplies the placeholder

basics

~20 s

React.lazy(() => import('./Page')) returns a component whose code loads on first render. The imported module must expose that component as its default export, and the lazy element must render inside a Suspense boundary that supplies a fallback.

solid answer

~40 s

`React.lazy` takes a factory that returns a promise — normally `() => import('./Settings')` — and gives you back a component. Because the `import()` is inside a function, the bundler puts that module in a separate chunk that is only fetched the first time the component actually renders. Two contracts come with it. First, the resolved module must have a `default` export that is a React component; for a named export you adapt it with `import('./Chart').then(m => ({ default: m.Chart }))`. Second, the lazy element must have a `<Suspense>` ancestor, because while the chunk is in flight React suspends that subtree and renders the nearest boundary's `fallback` instead. Without a boundary React throws. The call to `lazy` itself belongs at module scope, not inside a component body.

go deeper

for a junior

Be able to write the three lines from memory: the lazy constant at module scope, the dynamic import inside it, and a Suspense wrapper with a fallback. Say plainly that the module needs a default export.

for a middle

Explain why the dynamic import() call form is what creates a separate chunk, how React calls the loader exactly once and caches the result, and how to adapt a named export into the { default: Component } shape React requires.

for a senior

Show judgment about boundary placement — one fallback per route versus a tight boundary around a heavy widget — and about which modules are worth splitting at all, given that each split adds a network round trip at first render.

for a principal

Own the strategy: which split points a codebase standardises on, how loading placeholders stay consistent across teams, and how split boundaries interact with the app's server-rendering and preloading story rather than being chosen ad hoc per component.

## The problem it solves A React app compiled into one bundle makes every user download every screen, including the admin panel they will never open and the rich text editor they use once a month. Code splitting breaks the bundle into chunks that are fetched on demand. React's built-in way to express a split point is `React.lazy` paired with `<Suspense>`: `lazy` marks *what* is split, `Suspense` says *what the user sees* while the split code is still travelling over the network. ## The API `lazy` takes one argument — a function (often called the loader or factory) that returns a promise — and returns a component you render like any other: ```jsx import { lazy, Suspense } from 'react'; const Settings = lazy(() => import('./routes/Settings')); function App() { return ( <Suspense fallback={<PageSkeleton />}> <Settings /> </Suspense> ); } ``` The crucial detail is that `import('./routes/Settings')` sits *inside* a function body. A static `import ... from` at the top of the file is resolved at build time and pulled into the current chunk; a call expression is a runtime decision, and every bundler treats it as a chunk boundary. React never fetches anything itself — it just calls your loader at the right moment and waits on the promise you hand back. React calls that loader **once**. It stores the settled result on the lazy component, so a second render of `<Settings />` uses the already-loaded module with no further work. ## The default-export contract The promise must resolve to an object with a `default` property holding a component. That is exactly the shape an ES module namespace object has when the file does `export default function Settings() {…}`, which is why the plain form works with no ceremony. If your component is a named export, reshape the result yourself: ```js const Chart = lazy(() => import('./Chart').then((m) => ({ default: m.Chart })) ); ``` The same trick is how people load one component out of a barrel file, though pointing the loader straight at the real module is better — a barrel drags its siblings into the chunk. ## Why Suspense is mandatory While the loader's promise is pending, the lazy component has nothing to render, so React *suspends*: it walks up from that position to the nearest `<Suspense>` ancestor and renders that boundary's `fallback` in place of its children. When the promise resolves, React re-renders and swaps the real content in. If there is no `<Suspense>` anywhere above, React has nowhere to put a placeholder and the render fails with an error. The boundary does not have to be the immediate parent — any ancestor works, and the *nearest* one wins. One boundary can cover several lazy components, in which case the fallback stays until all of them have loaded. That is a placement decision: a boundary wrapping the whole page turns the entire screen into a skeleton, while a boundary wrapping only a lazy chart keeps the rest of the page interactive. ## Where the `lazy` call goes Declare it at module scope. Calling `lazy` inside a component body produces a brand-new component type on every render, which makes React unmount and remount the subtree instead of reusing it. The idiomatic file has the `lazy` constants next to the imports. ## What it is not - It is not a data-loading tool. `lazy` splits *code*; fetching the data the component needs is a separate concern. - It does not work for anything but components. You cannot `lazy` a hook or a utility function — for those, call `import()` yourself where you need it. - It does not change *when the component renders*, only when its code arrives. The first render of a lazy route still blocks on a network round trip, which is why hover- or focus-triggered preloading is a common companion technique. ## React 19 notes `lazy` is unchanged in React 19 and remains the API for component-level splitting; `use()` reads a promise's *value* inside a render but is not a replacement for a split boundary. Suspense also drives streaming server rendering, so on a server-rendered app the same boundary that shows your fallback in the browser is the unit the server can stream around — but that is the rendering model's concern, not the split point's. ## What interviewers listen for They want to hear the three moving parts named without prompting: the loader returns a promise, the module resolves with a `default` component, and a `Suspense` ancestor supplies the fallback. Candidates who can also say *why* the dynamic call form is what creates the chunk — rather than treating `lazy` as magic that talks to the bundler — are visibly a tier above.

  • Your component is exported as `export function Chart()` with no default export — can you still use React.lazy on it?
    Yes, but you have to hand React the shape it wants: `lazy(() => import('./Chart').then(m => ({ default: m.Chart })))`. React only ever looks at the `default` property of the resolved object, so any promise that settles with `{ default: SomeComponent }` works. Point the import at the real module rather than a barrel file, otherwise the barrel's other exports end up in the chunk.
  • Does the Suspense boundary have to be the direct parent of the lazy component?
    No. React walks up from the suspended component and uses the nearest `<Suspense>` ancestor, wherever it is. One boundary can cover several lazy components, and then its fallback stays visible until all of them have resolved. Placement is a UX decision: a boundary high in the tree replaces a whole screen, a boundary right around the lazy part keeps the surrounding UI on screen and interactive.
  • What happens if you render a lazy component with no Suspense boundary anywhere above it?
    React has nowhere to render a placeholder, so the render fails and the error propagates as an unhandled error — you get a crash rather than a silent blank area. This is why the boundary is described as a requirement of `lazy` rather than an optional nicety. In practice teams put a boundary at the route level so every lazily loaded screen inherits one.

saying these in an interview costs you the question

  • Thinking React.lazy fetches the chunk itself instead of calling your loader
  • Saying a static import at the top can be lazily loaded
  • Assuming named exports work without reshaping to default
  • Believing Suspense is optional and React just renders nothing
  • Calling React.lazy inside the component that renders it

context

open as a page

A React component's body contains `const Chart = lazy(() => import('./Chart'))`, and `<Chart />` is rendered below a search input. Typing in the input makes the Suspense fallback flash and resets the chart's zoom state every keystroke. Why, and what is the fix?

level: middleimportance: should knowfreq 45%

basics

~20 s

Calling lazy during render creates a brand-new component type on every render. React sees a different type at that position, unmounts the old subtree and mounts a fresh one, which loses state and suspends again. Move the lazy call to module scope.

open as a page

A React modal is loaded with React.lazy, so opening it shows a spinner for a beat. How do you make the chunk start downloading when the user hovers or focuses the button that opens it, and why does that not cause the module to load twice?

level: middleimportance: should knowfreq 38%

basics

~20 s

Keep the loader in a named function, pass it to React.lazy, and also call it from the button's onMouseEnter and onFocus handlers. The module registry caches by specifier, so React's later call to the same loader resolves from cache instead of fetching again.

open as a page

Shortly after a deploy, users with a tab open on a React SPA hit a crash when they navigate to a route rendered through React.lazy. At the React level, where does that failure surface, does the Suspense fallback catch it, and what does it take to recover in the UI?

level: seniorimportance: should knowfreq 48%

basics

~20 s

The old tab requests a hashed chunk that the new deploy removed, so the loader's promise rejects and React.lazy throws during render. Suspense handles pending loads only — an error boundary above the lazy component catches it, and recovery normally means reloading the page.

open as a page