C.W.K.
Stream
Lesson 02 of 07 · published

Code Splitting — Lazy Routes, Lazy Components

~11 min · code-splitting, lazy, performance

Level 0React Novice
0 XP0/54 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
Every byte of JS your user downloads is a byte they paid for. The bytes they never visit aren't worth paying for. Code splitting is how you ship only what each page needs.

The mechanism

const Heavy = React.lazy(() => import('./Heavy')). The dynamic import() tells Vite/Rollup 'this is a separate chunk.' At build time, Heavy ends up in its own JS file. At runtime, the chunk loads only when <Heavy /> is rendered. Combine with <Suspense> so the loading state is declarative.

Three places to split

  1. Per route — every page is a separate chunk. The user pays for the home page; clicking a deep link loads only that page's bundle.
  2. Per heavy component — a charting library, a Markdown renderer, anything >50KB used in only one or two places.
  3. Per modal / dialog — a settings modal might pull in form libraries; lazy-load when the user opens it.

The cost

Each chunk is a network round trip. Splitting too aggressively means waterfalls of small requests, which can be slower than one big bundle. Aim for chunks 30-200KB gzipped; smaller than that doesn't justify the round-trip cost.

Preloading

If you know the user is about to navigate to a route, you can preload its chunk. <link rel="modulepreload" href="..."> in HTML, or programmatically by simply touching the lazy component. Vite emits modulepreload tags for entry chunks automatically.

Split at user-perceivable boundaries. Routes are obvious. Dialogs are obvious. Splitting individual utility functions is over-engineering. The right granularity is 'what the user thinks of as a separate experience.'

Code

Route-level code splitting with React Router·tsx
import { lazy, Suspense } from "react";
import { Routes, Route } from "react-router-dom";

// Each lazy() call produces a separate chunk in the production build.
const Home = lazy(() => import("./pages/Home"));
const Settings = lazy(() => import("./pages/Settings"));
const Admin = lazy(() => import("./pages/Admin"));

export function App() {
  return (
    <Suspense fallback={<p>Loading page…</p>}>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/settings" element={<Settings />} />
        <Route path="/admin" element={<Admin />} />
      </Routes>
    </Suspense>
  );
}

// Initial load = main bundle + Home chunk. Navigating to /settings fetches
// the Settings chunk on demand. Admin never loads unless an admin visits.
Lazy modal — only load when opened·tsx
import { lazy, Suspense, useState } from "react";

const HeavySettingsModal = lazy(() => import("./HeavySettingsModal"));

function Header() {
  const [open, setOpen] = useState(false);
  return (
    <header>
      <button onClick={() => setOpen(true)}>Settings</button>
      {open && (
        <Suspense fallback={<p>Loading settings…</p>}>
          <HeavySettingsModal onClose={() => setOpen(false)} />
        </Suspense>
      )}
    </header>
  );
}

// The modal's bundle isn't downloaded until the user clicks Settings.

External links

Exercise

Convert your bootstrap project's routes from eager imports to React.lazy + Suspense. Build with npm run build and confirm you see one chunk per route in dist/assets/. Navigate around and watch the Network panel — each new route fetches its chunk on demand. Bonus: add a lazy modal triggered by a button and verify the modal's chunk only loads on first open.
Hint
If your routes still ship in the main bundle, you probably wrote const Home = await import('./Home') somewhere — that defeats the dynamic import. Stick to lazy(() => import('./Home')) exactly.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.