Blog

Part 34 · Code Splitting in React with lazy and Suspense

Load a component’s code only when the page needs it, with lazy and Suspense. We measure a real Vite build, then cover preloading, failed downloads and common mistakes.

In Part 10 we built an app for real users. All our components, and React itself, went into one JavaScript file. The browser downloads that whole file before it can show anything.

That’s fine for a small app. But apps grow. Soon the file holds code for screens that most visitors never open. Code splitting means cutting that one file into pieces. The browser downloads a piece only when the page needs it.

React gives you one tool for this: lazy. It works with <Suspense>, which you met in Part 31. This part shows both, first in the playground and then in a real Vite build. Then we cover when to load a piece early, and what happens when a download fails.

Try this first

The playground runs one file, so it can’t really split code. Here we pretend. fakeImport acts like a download of the chart’s code from a server. It waits one second, then gives back the chart.

Read this code. Don’t press Run yet.

import { lazy, Suspense, useState } from 'react'

function SalesChart() {
  return <p>Chart: Mon 3, Tue 5, Wed 2</p>
}

// Pretends to download the chart's code. It takes 1000 ms.
function fakeImport() {
  console.log('Downloading the chart code...')
  return new Promise<{ default: typeof SalesChart }>(resolve => {
    setTimeout(() => resolve({ default: SalesChart }), 1000)
  })
}

const Chart = lazy(fakeImport)

export default function App() {
  const [show, setShow] = useState(false)

  return (
    <div>
      <h1>Sales</h1>
      <button onClick={() => setShow(!show)}>
        {show ? 'Hide chart' : 'Show chart'}
      </button>
      {show && (
        <Suspense fallback={<p>Loading chart...</p>}>
          <Chart />
        </Suspense>
      )}
    </div>
  )
}
Downloading the chart code...

Make a guess. You click Show chart, then Hide chart, then Show chart again. How many times will the Console say “Downloading the chart code…”?

Now press Run and try it.

Nothing downloads when the page opens. The first click shows “Loading chart…” for one second, then the chart. The second time, the chart shows at once. The Console has one line, even with Strict Mode on. React asked for the code once, and then kept it.

Why the size of the code matters

Before a page can do anything, the browser must download its JavaScript. Then it must read and prepare that code. The word for reading code is parse.

Google’s guide to the Lighthouse speed test lists these costs. It says “More bytes equals longer download times”. It also says “JavaScript gets parsed and compiled on the main thread”. The main thread is the part of the browser that also handles your clicks. In the guide’s words, “When the main thread is busy, the page can’t respond to user input”.

A phone is slower than a laptop, and so is its network. So a big file costs more there. We measured it below, with a pretend slow phone.

The answer is not “write less code”. The chart still needs its code. The old React docs say it well. Code splitting doesn’t make your app smaller: “you’ve avoided loading code that the user may never need”.

import and import()

You know this line from Part 10:

import Chart from './Chart'

Each file of your code is a module, as Part 10 said. This line is a static import. Static means fixed. It sits at the top of a file and always runs. When the build tool sees it, it puts Chart into the same file as your app. Everything Chart needs goes in too.

There is a second kind, written like a function call:

import('./Chart').then(module => {
  console.log(module.default)
})

We built this in our project too. Vite made a separate Chart chunk, and the browser asked for it just after the page loaded.

This is a dynamic import. Dynamic means it happens while the app runs. It doesn’t load anything until that line runs. And it doesn’t give you the module at once. It gives you a promise, as in Part 13. MDN’s reference page says the promise is fulfilled with “an object containing all exports” of the file. The default export is in its default key.

When Vite sees import('./Chart'), it makes a separate file for Chart. A separate piece of a build is called a chunk.

lazy turns that promise into a component

lazy takes a function that returns such a promise. It gives back a component you can render:

const Chart = lazy(() => import('./Chart'))

React’s docs explain what happens next. React “will not call load until the first time you attempt to render the returned component”. load is the function you passed in. While the code is on its way, rendering Chart suspends, exactly as use did in Part 31. The closest <Suspense> above shows its fallback. When the promise is fulfilled, React takes the component from .default and renders it.

React also keeps the answer. The docs say “React will not call load more than once”. That’s why “Try this first” logged one line.

In a real project

Now let’s do it for real. We made a new project the way Part 10 does, with create-vite 9.2.1. A real chart needs a real library, so we added the chart.js library, version 4.5.1:

npm install chart.js@4.5.1

We also deleted the starter’s CSS files and pictures. Then we wrote src/Chart.tsx. It draws a bar chart on a <canvas>. It uses a ref from Part 14 and an Effect with a cleanup from Part 12:

import { useEffect, useRef } from 'react'
import { Chart as ChartJS, registerables } from 'chart.js'

ChartJS.register(...registerables)

function Chart() {
  const canvasRef = useRef<HTMLCanvasElement>(null)

  useEffect(() => {
    const chart = new ChartJS(canvasRef.current!, {
      type: 'bar',
      data: {
        labels: ['Mon', 'Tue', 'Wed'],
        datasets: [{ label: 'Sales', data: [3, 5, 2] }],
      },
    })
    return () => chart.destroy()
  }, [])

  return <canvas ref={canvasRef} />
}

export default Chart

You don’t need to learn chart.js for this part. Only its size matters.

Before: one file

First, App.tsx imports the chart the usual way:

import { useState } from 'react'
import Chart from './Chart'

function App() {
  const [show, setShow] = useState(false)

  return (
    <div>
      <h1>Sales</h1>
      <button onClick={() => setShow(true)}>Show chart</button>
      {show && <Chart />}
    </div>
  )
}

export default App

npm run build ended with these lines:

dist/index.html                  0.38 kB │ gzip:   0.26 kB
dist/assets/index-DM6rL_op.js  422.61 kB │ gzip: 137.81 kB

One JavaScript file, 422.61 kB. It holds React, our app and all of chart.js. Every visitor downloads all of it, even one who never clicks the button.

After: two files

Now change two lines in App.tsx. The chart comes from lazy, and a <Suspense> goes around it:

import { lazy, Suspense, useState } from 'react'

const Chart = lazy(() => import('./Chart'))

function App() {
  const [show, setShow] = useState(false)

  return (
    <div>
      <h1>Sales</h1>
      <button onClick={() => setShow(true)}>Show chart</button>
      {show && (
        <Suspense fallback={<p>Loading chart...</p>}>
          <Chart />
        </Suspense>
      )}
    </div>
  )
}

export default App

npm run build again. It ended with:

dist/index.html                  0.38 kB │ gzip:  0.26 kB
dist/assets/Chart-DHqMMmaX.js  202.49 kB │ gzip: 69.45 kB
dist/assets/index-DQ0A3UE-.js  221.30 kB │ gzip: 69.26 kB

Now there are two files. index has React and the app: 221.30 kB. The new chunk, Chart, has the chart and chart.js: 202.49 kB. Vite named the chunk after our file. Together, the two files are a little bigger than the one file was. Nothing was saved in total. The saving is in what the first visit needs.

What the browser asks for

We served the build with npm run preview and opened it in a headless Chromium. Headless means a browser with no window, run by a program. We recorded every file the page asked for.

1. The build made two files. Both wait on the server. 2. You open the page. The browser downloads only the index file. 3. You click Show chart. React shows the fallback and asks for the Chart file. 4. The Chart file arrives. React renders the chart in place of the fallback. the browser the server: dist/assets/ downloaded: SalesShow chart Loading chart… index-DQ0A3UE-.js221 kB Chart-DHqMMmaX.js202 kB index-DQ0A3UE-.js221 kB Chart-DHqMMmaX.js202 kB

The first visit downloads one file. The chart’s file comes only when you ask for the chart. Press play, or step through it.

Here are the same steps in words.

  1. The build made two files. Both wait on the server.
  2. You open the page. The browser asks for index.html and index-DQ0A3UE-.js. Nothing else. The page shows “Sales” and the button.
  3. You click Show chart. React tries to render Chart, and lazy calls import('./Chart'). The browser asks for Chart-DHqMMmaX.js. Meanwhile the page shows “Loading chart…”.
  4. The chunk arrives. React renders Chart, and the <canvas> takes the place of the fallback.

In one run, the chunk came from our own computer in 15 ms. But “Loading chart…” stayed on the page for 338 ms. That is the 300 ms rule from Part 31. React shows new content in a boundary at most once every 300 ms, counted from the last time it showed something. Here, the chart came about 300 ms after the fallback appeared.

On a slow phone

Then we made the browser slow on purpose. We used the network numbers that Google’s Lighthouse test uses for phones. Chromium’s developer tools applied them to each request. Every request waited an extra 150 ms. The download speed was about 1.7 million bits per second. We also made the computer run 4 times slower than this computer normally runs. Then we timed seven visits to each build. Each time ran from the start of the visit until the button was on the page.

Build JavaScript sent over the network Button on the page: middle of 7 visits (and the range of all 7)
One file 136,853 bytes 1603 ms (1531 to 1633 ms)
Split with lazy 68,944 bytes 1050 ms (1021 to 1261 ms)

The server packed each file with gzip before sending it. That is why the bytes are smaller than the file sizes. The split build sent half the bytes, and every visit showed the button sooner. This is one pretend phone on one computer, not a promise for every user. vite preview is also not a real production server. A real host may pack files another way, or not at all. But the direction is clear: less code first, a page that is ready sooner.

The rules of lazy

Rule 1: the file needs a default export

React reads the component from .default. The docs say the component must be “exported as the default export”. Part 10 explained the two kinds of export.

What if Chart.tsx has a named export, export function Chart()? Then import('./Chart') gives { Chart: ... }, with no default. In our project, npm run build stopped with error TS2322. TypeScript said the promise has the wrong type: lazy wants one with a default.

The fix is to make a default yourself. .then turns the module into a new object:

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

We built this in the project. It made the same two files, and the chart showed after the click. Here is the same idea in the playground. This fakeImport pretends to load a file whose export is named SalesChart:

import { lazy, Suspense } from 'react'

function SalesChart() {
  return <p>Chart: Mon 3, Tue 5, Wed 2</p>
}

// Pretends to download a file whose export is named SalesChart.
function fakeImport() {
  return new Promise<{ SalesChart: typeof SalesChart }>(resolve => {
    setTimeout(() => resolve({ SalesChart }), 1000)
  })
}

const Chart = lazy(() =>
  fakeImport().then(module => ({ default: module.SalesChart }))
)

export default function App() {
  return (
    <Suspense fallback={<p>Loading chart...</p>}>
      <Chart />
    </Suspense>
  )
}

Rule 2: call lazy at the top of the file

The docs say: “Call lazy outside your components”. Here is why. This app calls lazy inside App:

import { lazy, Suspense, useState } from 'react'

function SalesChart() {
  const [year, setYear] = useState(2025)
  return (
    <div>
      <p>Chart for {year}</p>
      <button onClick={() => setYear(year + 1)}>Next year</button>
    </div>
  )
}

// Pretends to download the chart's code. It takes 1000 ms.
function fakeImport() {
  console.log('Downloading the chart code...')
  return new Promise<{ default: typeof SalesChart }>(resolve => {
    setTimeout(() => resolve({ default: SalesChart }), 1000)
  })
}

export default function App() {
  const [likes, setLikes] = useState(0)
  // Wrong: this makes a new lazy component on every render.
  const Chart = lazy(fakeImport)

  return (
    <div>
      <button onClick={() => setLikes(likes + 1)}>Likes: {likes}</button>
      <Suspense fallback={<p>Loading chart...</p>}>
        <Chart />
      </Suspense>
    </div>
  )
}
Downloading the chart code...
Downloading the chart code...

Run it. Wait for the chart, and click Next year twice, so it says 2027. Then click Likes.

The chart goes away, and “Loading chart…” comes back. The Console says it is downloading again. After one second, the chart is back.

Each render of App calls lazy again and gets a brand new lazy component. That new component has never loaded. So it suspends, calls load again, and the boundary shows its fallback. Every click on Likes does this again.

React’s docs warn about one more thing: “This will cause all state to be reset on re-renders”. In our test with React 19.3, the chart still said 2027. Once the new lazy component loaded, it gave back the very same SalesChart function. React saw the same component and kept it. In the real project, the year stayed too. The browser didn’t download the file again, but “Loading chart…” still showed.

That only worked because load gave back the same function each time. We changed load to wrap the chart in a new small component: .then(m => ({ default: () => <m.default /> })). Then each load gave a new component, and after Likes the chart said 2025 again.

While “Loading chart…” shows, the old chart is still there, hidden with display: none. React’s docs say it cleans up layout Effects at that point. A normal Effect keeps running. In our real-project test, the chart.js chart wasn’t destroyed, and the same <canvas> stayed. So this mistake costs a fallback and a wait on every render of App. Sometimes it also loses state.

Oxlint, the linter from Part 10, catches this mistake. On this code it printed a warning:

! react(static-components): Cannot create components during render

The fix: move const Chart = lazy(fakeImport) out of App, to the top of the file. Then there is one Chart for the whole app.

Rule 3: put a <Suspense> above it

A lazy component suspends while its code is on the way, so something must show a fallback. We took the <Suspense> out of “Try this first” and clicked the button. With no boundary above Chart, the whole app waited. The old screen stayed, and the button still said “Show chart”. After one second, everything changed at once. In Part 31, the same thing happened on the first render, so the page was empty while it waited. Here it was an update, so the old screen stayed.

The boundary can be anywhere above the lazy component, and one boundary can wait for several of them.

Where to split

Splitting has a cost too. Each chunk is one more request, and the user waits for it while the fallback shows. So split where it helps most:

  • Pages. Each page of an app is a good piece. The old React docs call routes “A good place to start”. A route is the code for one address, like /settings. Part 37 builds a small router.
  • Things that open rarely. A settings dialog, a help screen, a window for printing. Many users never open them.
  • Big libraries. Charts, maps and text editors often bring large libraries, like chart.js here.

Many apps don’t call lazy for pages at all. A router is the code that picks which page to show for each address. React’s docs say routers “are usually integrated with” code splitting. So a framework, like the one in Part 33, often splits your code by page for you. lazy matters most in apps you set up yourself, like the Vite app from Part 10.

Don’t split small components. We gave a tiny component its own chunk:

function Hello() {
  return <p>Hello!</p>
}

export default Hello

Its chunk was 125 bytes. It took one more request. In our one run, the page showed “Loading chart…” for 302 ms. That wait gains you almost nothing.

Load it early

A click is not the first sign that the user wants the chart. First, the mouse pointer moves onto the button and rests there. This is called a hover. You can start the download then. Keep the import function in a variable, and call it on hover too:

const loadChart = () => import('./Chart')
const Chart = lazy(loadChart)
<button
  onPointerEnter={loadChart}
  onFocus={loadChart}
  onClick={() => setShow(true)}
>
  Show chart
</button>

onPointerEnter runs when the mouse pointer moves onto the button. onFocus runs when the button gets focus, for example from the Tab key. When Chart renders, lazy calls loadChart again. That’s fine: we checked, and the browser asked for the chunk only once.

We measured it in the browser. Every chunk was held back for an extra 500 ms, like a slow network. Our script moved the pointer onto the button and clicked a moment later. We did this seven times for each case. Times start just before our script clicked. “Chart on the page” means the chart’s <canvas> was there. chart.js then draws the bars with a short animation.

App Pointer on the button before the click Chunk arrived Chart on the page: middle (and the range of all 7) Fallback seen
lazy only 300 ms 535 ms 579 ms (539 to 645 ms) 7 of 7
loadChart on hover 300 ms 215 ms 361 ms (348 to 383 ms) 7 of 7
loadChart on hover 800 ms 287 ms before the click 363 ms (348 to 366 ms) 7 of 7

Look at the last row. The chunk was already there before the click, but the chart still took about as long. lazy still suspends once the first time it renders, even when the code is ready. So the fallback shows, and the 300 ms rule keeps it there.

A Transition fixes that. Keep the <Suspense> on the page all the time, and show the chart inside a Transition, as in Part 31. startTransition can also be imported from React on its own, without useTransition:

import { lazy, startTransition, Suspense, useState } from 'react'

const loadChart = () => import('./Chart')
const Chart = lazy(loadChart)

function App() {
  const [show, setShow] = useState(false)

  return (
    <div>
      <h1>Sales</h1>
      <button
        onPointerEnter={loadChart}
        onFocus={loadChart}
        onClick={() => startTransition(() => setShow(true))}
      >
        Show chart
      </button>
      <Suspense fallback={<p>Loading chart...</p>}>
        {show && <Chart />}
      </Suspense>
    </div>
  )
}

export default App

In a Transition, React keeps the old screen until the chart is ready, instead of showing the fallback:

App Pointer on the button before the click Chart on the page: middle (and the range of all 7) Fallback seen
hover and Transition 300 ms 270 ms (221 to 296 ms) 0 of 7
hover and Transition 800 ms 64 ms (33 to 85 ms) 0 of 7

With the code already loaded, the chart showed in well under 100 ms, with no fallback at all. Without the hover, a Transition keeps the old screen until the code arrives. So give the user a sign that something is happening, like the isPending note in Part 31.

On a touch screen

A phone has no mouse, so there is no hover. We tapped the button on a pretend phone screen. pointerenter fired at the same moment as the tap’s pointerdown, within 1 ms, and the chunk was asked for then. The click event came 1 or 2 ms later. So loading on hover gains almost nothing on a touch screen.

Other moments can work there. You can start the download when the button scrolls into view, or a little while after the page has loaded. We didn’t measure these.

Avoid a chain of downloads

Lazy components can hide inside other lazy components. Here a Dashboard page is lazy. Inside it, the chart is lazy too:

import { lazy, Suspense } from 'react'

const Chart = lazy(() => import('./Chart'))

function Dashboard() {
  return (
    <section>
      <h2>Dashboard</h2>
      <Suspense fallback={<p>Loading chart...</p>}>
        <Chart />
      </Suspense>
    </section>
  )
}

export default Dashboard

The browser can’t know about the chart’s chunk until the code of Dashboard has arrived and run. So it downloads one, then the other. This chain is a waterfall. Part 13 used the same word for requests that wait for each other.

With 500 ms extra per chunk, we clicked the button that opens it. We did this seven times for each version. The table shows the range of all seven.

Dashboard Requests Chart on the page after the click
lazy, with a lazy chart inside Dashboard chunk, then Chart chunk about 1.1 s (1107 to 1162 ms)
lazy, with a normal import Chart inside one Dashboard chunk about 0.6 s (565 to 697 ms)

The chained version also showed two fallbacks, one after the other. When two pieces always show together, keep them in one chunk. Use a normal import inside the lazy page.

Keep the old page with a Transition

Say a lazy component suspends inside a boundary that is already on the page. Then the boundary hides its old content and shows its fallback. With tabs, the old tab goes away while the new one loads.

Part 31 showed the fix: a Transition. It works the same for code as for data:

import { lazy, Suspense, useState, useTransition } from 'react'

function Photos() {
  return <p>Photos: 3 pictures</p>
}

function Comments() {
  return <p>Comments: 2 comments</p>
}

// Pretends to download a component's code. It takes `ms` milliseconds.
function fakeImport<T>(component: T, ms: number) {
  return new Promise<{ default: T }>(resolve => {
    setTimeout(() => resolve({ default: component }), ms)
  })
}

const PhotosTab = lazy(() => fakeImport(Photos, 500))
const CommentsTab = lazy(() => fakeImport(Comments, 1500))

export default function App() {
  const [tab, setTab] = useState('photos')
  const [isPending, startTransition] = useTransition()

  function choose(next: string) {
    startTransition(() => {
      setTab(next)
    })
  }

  return (
    <div>
      <button onClick={() => choose('photos')}>Photos</button>
      <button onClick={() => choose('comments')}>Comments</button>
      {isPending && <p>Opening...</p>}
      <Suspense fallback={<p>Loading tab...</p>}>
        {tab === 'photos' ? <PhotosTab /> : <CommentsTab />}
      </Suspense>
    </div>
  )
}

Run it. Wait for the photos, then click Comments. The photos stay, with “Opening…” above them. After about one and a half seconds, the comments take their place.

Without startTransition, the click would hide the photos and show “Loading tab…” instead. The first load still shows “Loading tab…”. There is no old content yet to keep.

When the download fails

A chunk comes over the network, and networks fail. Then the promise from import() is rejected. React’s docs say that React then throws the error to the nearest error boundary. Part 32 built error boundaries.

Here a pretend download always fails:

import { Component, lazy, Suspense, type ReactNode } from 'react'

type Props = { children: ReactNode }
type State = { error: Error | null }

class ErrorBoundary extends Component<Props, State> {
  state: State = { error: null }

  static getDerivedStateFromError(error: Error): State {
    return { error: error }
  }

  render() {
    if (this.state.error) {
      return <p>The chart could not load: {this.state.error.message}</p>
    }
    return this.props.children
  }
}

function SalesChart() {
  return <p>Chart: Mon 3, Tue 5, Wed 2</p>
}

// Pretends to download the chart's code, but the network is down.
function fakeImport() {
  return new Promise<{ default: typeof SalesChart }>((_resolve, reject) => {
    setTimeout(() => reject(new Error('Failed to fetch')), 1000)
  })
}

const Chart = lazy(fakeImport)

export default function App() {
  return (
    <div>
      <h1>Sales</h1>
      <ErrorBoundary>
        <Suspense fallback={<p>Loading chart...</p>}>
          <Chart />
        </Suspense>
      </ErrorBoundary>
    </div>
  )
}

Run it. “Loading chart…” shows for one second. Then the boundary shows its message, and “Sales” stays. We also tried it with no boundary. Then the whole app went off the page, and the playground showed the error Failed to fetch.

We did the same in the real project. Our headless browser blocked the request for the chart chunk, as if the network were down. The browser’s error was:

TypeError: Failed to fetch dynamically imported module: http://localhost:4734/assets/Chart-Bc-H3ETm.js

The boundary caught it and showed “The chart could not load.”

“Try again” doesn’t try again

Part 32’s boundary had a Try again button. We added one, let the network come back, and clicked it. The same error showed at once. The browser didn’t even ask for the chunk again.

lazy remembers the failure, the same way it remembers success. It never calls load a second time. We saw this in the playground too, with a pretend download that failed once and then worked. After Try again, load had still run only once.

We also tried a second import('./Chart') inside load, in a .catch. It didn’t help. Chromium didn’t ask the server again, and the second try failed too.

What did work was a fresh page. We added a button that runs location.reload(), which loads the whole page again. After the reload, the chunk downloaded and the chart showed.

Failed chunks also happen after you put a new version of your app online. Your server may delete the old chunks while a user still has the old page open. Vite’s docs describe this case. Vite sends an event, vite:preloadError, when a chunk fails to load. The example in the docs listens for it and reloads the page. The docs add one more step: the server must send the HTML file with Cache-Control: no-cache. Otherwise the browser may keep the old HTML, which still points to the old chunks. And reload only once. If the chunk is truly gone, a page that reloads on every error would reload forever. One way is to note the reload in sessionStorage and skip it the second time.

Common mistakes

Calling lazy inside a component

You saw this under Rule 2. Each render makes a new lazy component, so the code loads again and the fallback comes back. React printed no warning in our test, but Oxlint did. Move the lazy call to the top of the file.

No <Suspense> above a lazy component

Nothing breaks with an error. But the closest boundary might be far up the tree, or there might be none. Then a big part of the page, or all of it, waits for one chunk. Put a <Suspense> close to the lazy component, with a fallback that fits the space.

A file with no default export

import { lazy } from 'react'

function SalesChart() {
  return <p>Chart: Mon 3, Tue 5, Wed 2</p>
}

const Chart = lazy(() => Promise.resolve({ SalesChart }))

TypeScript stops you here: “Property ‘default’ is missing”. The playground doesn’t check types. There, React fails when it tries to render the component. We rendered <Chart /> inside a <Suspense>. The Console showed this line twice:

lazy: Expected the result of a dynamic import() call. Instead received: { SalesChart: [Function: SalesChart] }

Then React stopped with this error:

Element type is invalid. Received a promise that resolves to: undefined. Lazy element type must resolve to a class or function.

The object after “Instead received” may look different in your browser’s Console.

Use .then(module => ({ default: module.SalesChart })), as in Rule 1.

Importing the same file the normal way somewhere else

We kept lazy(() => import('./Chart')) in App.tsx. Then we added a second file, Report.tsx, with a normal import Chart from './Chart', and showed it in App. The build made one JavaScript file again, 424.47 kB, and printed a warning:

[INEFFECTIVE_DYNAMIC_IMPORT] src/Chart.tsx is dynamically imported by src/App.tsx but also statically imported by src/Report.tsx, dynamic import will not move module into another chunk.

A static import anywhere pulls the file into the main chunk. Read the build’s warnings, and import a split file only through import().

Splitting tiny components

A chunk of 125 bytes saves almost nothing, and it still costs a request and a fallback. Split pages, rare screens and big libraries.

Forgetting that a download can fail

Without an error boundary, one failed chunk takes the whole app off the page. Put an error boundary around your lazy parts, and give the user a way back. A reload button worked in our test, and Try again didn’t.

Practice

Press Edit on any example above and try these.

  1. In “Try this first”, change 1000 to 3000. Click Show chart, Hide chart and Show chart. How long does “Loading chart…” stay each time?
  2. Fix the Rule 2 example: move const Chart = lazy(fakeImport) above App. Pick 2027 and click Likes. What does the chart say now? How many lines are in the Console?
  3. In the Transition example, change choose so it calls setTab(next) without startTransition. Wait for the photos, then click Comments. What do you see?
  4. In the failing-download example, move <ErrorBoundary> so it wraps the whole <div>, including the <h1>. What does the page show after one second?
Answers
  1. About 3 seconds the first time. The second time, the chart shows at once, with no “Loading chart…”. lazy called fakeImport only once and kept the answer.
  2. It still says “Chart for 2027” after Likes, and no fallback shows. The Console has one line. Now there is one Chart for the whole app, so React keeps the same component and its state.
  3. The photos go away, and “Loading tab…” shows for about one and a half seconds. Then the comments appear. “Opening…” never shows, because nothing is in a Transition.
  4. Only “The chart could not load: Failed to fetch”. “Sales” is gone too, because it is now inside the boundary that shows the message.

Interview questions

Try to answer each one out loud before you open the answer.

What is code splitting, and why do it?

The build tool cuts your JavaScript into several files, called chunks. The browser downloads the first one when the page opens, and the rest only when the page needs them. It doesn’t make the app smaller in total. Instead, the first visit needs less code, and the page is ready sooner. This matters most on slow phones and networks. In our test, a chart library moved out of the main file. The first file dropped from 422.61 kB to 221.30 kB.

A strong answer adds the cost. Every chunk is one more request and one more wait, so split where the saving is big.

What does lazy do, and what must the load function return?

lazy(load) gives back a component. React calls load the first time it tries to render that component, not before. load must return a promise, usually from import(). The promise must be fulfilled with an object whose default is a component. While the promise is pending, rendering suspends, and the nearest <Suspense> shows its fallback. React keeps the result and never calls load again.

How do you use lazy with a named export?

lazy reads .default, so turn the module into an object that has one: lazy(() => import('./Chart').then(module => ({ default: module.Chart }))). The old React docs give a second way. Make a small file that exports the component again, as its default export.

Why must lazy be called outside components?

If a component calls lazy, every render makes a new lazy component. The new one has never loaded, so it suspends and calls load again. The user sees the fallback on every render of the parent. React’s docs also warn that state is reset. In our test with React 19.3, the state was kept when load gave back the same component. It was lost when load gave back a new wrapper. Oxlint flags this with react(static-components). Call lazy once, at the top of a file.

Where would you split an app?

At pages first, because users expect a short wait when they move to another page. Then at things many users never open, like a settings dialog. Then around big libraries, like charts, maps and editors. Not around small components: a tiny chunk saves almost nothing and still costs a request.

A strong answer mentions waterfalls. A lazy component inside another lazy component downloads in two steps, one after the other. If they always show together, keep them in one chunk.

A lazy chunk fails to download. What happens, and how do you recover?

The promise from import() is rejected, and React throws the error to the nearest error boundary. With no boundary, the whole app goes off the page. Recovering is harder than it looks. lazy remembers the failure. So after a reset, the boundary shows the same error, with no new request. In our test, Chromium didn’t even try a second import() of the same file: it failed with no new request. Reloading the page worked.

A strong answer mentions new releases. After a new version goes online, old chunks may be gone from the server. Vite sends a vite:preloadError event for that case, and the example in its docs reloads the page.

How can you make a lazy component appear sooner?

Start the download before React needs it. Keep the import function in a variable, like loadChart. Call it when the user shows interest: on hover with onPointerEnter, and with onFocus for keyboard users. When the click comes, part of the wait is already over. But lazy still suspends once, so the fallback shows for about 300 ms even when the code is ready. Put the <Suspense> on the page first, and show the chart inside a Transition. Then React keeps the old screen and shows the chart with no fallback. A strong answer adds that a touch screen has no hover. There, other moments work better, like when the button scrolls into view.

Sources

  • lazy, react.dev: “Call lazy outside your components”, when React calls load, “React will not call load more than once”, the .default rule and the default export, rejected promises and the nearest error boundary, and “This will cause all state to be reset on re-renders”.
  • <Suspense>, react.dev: lazy-loaded code is one of the things Suspense waits for.
  • useTransition, react.dev: isPending and startTransition.
  • Code-Splitting, the old React docs: bundles, “you’ve avoided loading code that the user may never need”, routes as “A good place to start”, named exports, Transitions for tabs, and error boundaries for network failures.
  • import(), MDN: import() returns a promise of “an object containing all exports”, with the default export under default.
  • Reduce JavaScript execution time, Chrome for Developers: “More bytes equals longer download times” and the main thread quote.
  • Lighthouse throttling, GoogleChrome/lighthouse: 150 ms latency, 1.6 Mbps down, 750 Kbps up, and the 4x CPU slowdown.
  • Building for Production, vite.dev: vite:preloadError, old chunks after a new release, and Cache-Control: no-cache on the HTML file.
  • Build a React App from Scratch, react.dev: routers “are usually integrated with” code splitting, and splitting code by route.
  • Every build size, file name, request, time and error text above comes from running React 19.3.0 for this post: in jsdom for the playground examples, and in a real project (create-vite 9.2.1, Vite 8.3.3, chart.js 4.5.1, Oxlint 1.87.0, Node.js 24.18.0) built with npm run build, served with vite preview and opened in headless Chromium from Playwright 1.62.1.
  • This part follows the Code Splitting kata in react-katas.

How useful was this post?

Click on a heart to rate it!

Average rating 0 / 5. Vote count: 0

No votes so far! Be the first to rate this post.