Skip to content

Code splitting

By default, a bundler rolls your whole app into one JavaScript file, and every import at the top of a file adds to it. That single bundle grows with the app, and the browser has to download all of it before anything renders.

On a fast machine with strong Wi-Fi you may never notice. On a phone with a weak connection, one heavy library can block the entire first load, including features that need nothing from that library. The course demonstrates this with a product list that depends on a large fake-data package: with everything in one bundle, even the counter on the same page stays blank for over twenty seconds on a throttled connection.

Code splitting is the fix. It breaks the bundle into pieces, called chunks, and downloads a chunk only if and when the user actually needs it. Features the user never touches are never downloaded at all.

Dynamic import()

The primitive underneath is plain JavaScript. A top-of-file import always runs. Calling import() as a function instead loads a module on demand, from wherever in your code you call it, and returns a promise that resolves with the module.

jsx
// always loaded, part of the main bundle
import { formatPrice } from './format'

// loaded only when this handler runs
async function showChart() {
  const { renderChart } = await import('./charts')
  renderChart()
}

Bundlers treat every import() call as a cut line: the imported module, plus the dependencies it does not share with the main bundle, get their own chunk, fetched over the network the first time the call runs.

lazy and Suspense

React wraps this primitive in a pair of tools. lazy takes a function that returns a dynamic import() and gives you back a component you can render like any other. The loader's promise has to resolve to a module whose default export is the component; other named exports alongside it are fine. For a file you do not control that only exports named components, adapt the promise instead: lazy(() => import('./charts').then(m => ({ default: m.RevenueChart }))).

Rendering a lazy component suspends: React pauses rendering that part of the tree until the chunk arrives. On its own that is an error, so you wrap the lazy component in a Suspense boundary with a fallback prop, some UI to show while the download is in flight.

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

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

export default function App() {
  const [showProducts, setShowProducts] = useState(false)

  return (
    <>
      <Counter />
      <button onClick={() => setShowProducts(prev => !prev)}>
        Show products
      </button>
      {showProducts && (
        <Suspense fallback={<h2>Loading...</h2>}>
          <ProductsList />
        </Suspense>
      )}
    </>
  )
}

Now the main bundle no longer contains ProductsList or its heavy dependency. The counter appears immediately. Clicking the button starts the chunk download, the fallback shows while it loads, and the component renders when it lands. After that first load the module is cached, so hiding and showing it again is instant.

Version note

lazy and Suspense are named imports from react. Older code, including the course lessons, writes them as React.lazy and React.Suspense; they are the same APIs.

Split on routes first

You could hunt for individual heavy components, but the default cut line in most apps is the route. Each page is a natural unit: a visitor on the home page has no use for the dashboard's code, so make every page a lazy component and let routing decide which chunks ever download.

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

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

export default function App() {
  return (
    <BrowserRouter>
      <Suspense fallback={<h2>Loading...</h2>}>
        <Routes>
          <Route path="/" element={<Home />} />
          <Route path="/vans" element={<Vans />} />
          <Route path="/host" element={<Dashboard />} />
        </Routes>
      </Suspense>
    </BrowserRouter>
  )
}

One Suspense boundary around the routes covers every page. First load fetches the main bundle plus only the chunk for the current URL; other pages download when the user navigates to them. That breadth has a cost: navigating to a page whose chunk has not arrived swaps the entire route area for the fallback, so keep shared layout like the header and nav outside the boundary and they stay put across the swap.

Splitting has a floor, though. Every chunk costs an extra network request and a moment of fallback UI, so splitting a tiny component saves nothing worth that price. Leave small components, and anything above the fold that users see immediately, in the main bundle. Split what is heavy, rarely used, or behind a navigation.

The split happens at build time. The bundler statically finds every import() call, emits a separate chunk for that module graph, and rewrites the call into a fetch for that chunk's URL.

lazy itself is thin: it holds the promise from your loader function and, on first render, throws it, which is the suspension signal. React catches it, walks up to the nearest Suspense boundary, commits the fallback, and retries the render once the promise resolves. The loader runs once; after that the resolved component is cached on the lazy wrapper and renders synchronously, and the browser cache makes revisits cheap even across reloads.

Boundary placement is a user-experience decision. A single boundary near the root means any suspending descendant blanks a large region and can cause layout shift when content lands; a boundary scoped tightly around the lazy subtree keeps the rest of the page interactive, which is the choice the course makes.

The route table is the exception that reconciles the two rules: a broad boundary there earns its keep because a navigation replaces that whole region anyway, and the fallback stands in for a page that was about to be swapped out. Inside a page, scope tightly.

When a chunk fails to load

Plan for the chunk that never arrives. A failed fetch rejects the loader's promise, and Suspense covers the pending state alone: React rethrows the rejection during render, and a render error that nothing catches unmounts the whole tree.

Pair the boundary with an error boundary, a component that catches a render error below it and shows fallback UI in its place. React's built-in version is the one thing in this handbook that still has to be a class component, since there is no hook equivalent, which is why most projects install react-error-boundary and wrap the routes with its ErrorBoundary instead.

The usual cause is a stale deploy, where an open tab requests a hashed chunk the new build no longer serves, and what the user needs there is a page reload rather than a retry.

Watch for waterfalls too: a lazy route that starts fetching data only after its chunk arrives serializes two round trips. React Router's data APIs, where a route's loader runs before render, or preloading on intent (starting the import() on link hover or focus, before render needs it) collapse that. Calling the same import('./ProductsList') early is safe; the module registry deduplicates it, and the later lazy render finds the chunk already in flight or done.

JunoDownload code only when it's needed Normally the browser has to download all of your app's JavaScript before showing anything, and one big feature can hold everything else hostage. Code splitting lets you say "load this part later, only if the user asks for it."

You wrap a dynamic import in lazy, put the component inside Suspense with a fallback like a loading message, and React shows the fallback while the code downloads. The rest of your app appears right away.

JunoDownload code only when it's neededconst Page = lazy(() => import('./Page')) gives you a component whose code lives in its own chunk, fetched on first render; the file must default-export the component, and a Suspense boundary with a fallback covers the download.

Make routes your default split points: each page a lazy component, one boundary around the route table. Skip splitting tiny components and above-the-fold UI, since each chunk costs a request.

JunoDownload code only when it's needed The bundler cuts a chunk at every import() call site at build time; lazy throws the loader promise on first render, the nearest Suspense boundary shows its fallback, and the resolved module is cached so later renders are synchronous.

Scope boundaries to control how much UI a suspension blanks, and preload on intent or let the router fetch a route's data before render to avoid chunk-then-data waterfalls. Duplicate import() calls deduplicate, so warming a chunk early is free.

Next up: Authentication, where sessions, tokens, and server-side enforcement come together.