π¦ Code Splitting and Lazy Loading
Memoization made your components render faster once they're on screen. But there's a cost that happens before anything renders at all: downloading and parsing your JavaScript. Code splitting is how you stop shipping the whole app on the first visit β you load the code for a screen only when a user actually goes there.
Week 5 · Day 4 (Thursday: Performance Optimization) · Lecture 3
π― Learning Objectives
By the end of this lesson, you will be able to:
- Explain what a bundle is and why a large initial bundle hurts load time
- Split a component out of the main bundle with
React.lazyand a dynamicimport() - Wrap lazy components in
Suspenseand provide meaningfulfallbackUI - Apply route-based code splitting to a React Router app
- Catch load failures with an error boundary and let users retry
- Preload chunks on hover or intent so lazy loading feels instant
Estimated Time: 70 minutes
Practice: Convert an eagerly-imported route tree to lazy routes with a shared fallback and error boundary.
In This Lesson
The Bundle Problem
Imagine a library that made you carry every book home just to read one chapter. Exhausting β and pointless. A good librarian brings you only the book you need, when you ask for it. Code splitting turns your app's build into that librarian.
When you build a React app, a bundler (Vite, webpack, and friends) stitches all your imported modules into one or a few JavaScript files β the bundle. The browser must download and parse that whole bundle before your app becomes interactive. Add a rich text editor, a charting library, an admin panel, and a checkout flow, and a first-time visitor to your homepage pays to download all of it β even though they only saw the homepage.
Code splitting breaks that single bundle into smaller chunks that load independently. The essential code loads first for a fast initial paint; everything else loads on demand.
editor + charts + admin + checkout] B -->|Code split| D[Download main chunk only
homepage essentials] C --> E[Slow first paint] D --> F[Fast first paint] F --> G[Load chart chunk
when user opens a chart] F --> H[Load admin chunk
only for admins]
π The magic word: dynamic import()
A normal import X from './X' at the top of a file is static β the bundler includes X in the main chunk. A dynamic import('./X') called as a function returns a Promise and tells the bundler: "put X in its own chunk and fetch it at runtime." That single syntax is the foundation of every technique in this lesson.
React.lazy & Suspense
React gives you two pieces that turn a dynamic import into a real component. lazy() wraps a dynamic import and produces a component you can render like any other. Suspense wraps that lazy component and shows a fallback while its chunk is still downloading.
Before: everything loads at once
// Every one of these is bundled into the main chunk, downloaded up front
import Dashboard from './Dashboard';
import Analytics from './Analytics';
import AdminPanel from './AdminPanel';
function App() {
return (
<div>
<Dashboard />
<Analytics />
<AdminPanel />
</div>
);
}
After: each loads on demand
import { lazy, Suspense } from 'react';
// Each becomes its own chunk, fetched only when first rendered
const Dashboard = lazy(() => import('./Dashboard'));
const Analytics = lazy(() => import('./Analytics'));
const AdminPanel = lazy(() => import('./AdminPanel'));
function App() {
return (
<div>
{/* One Suspense can wrap several lazy children with a shared fallback */}
<Suspense fallback={<p>Loadingβ¦</p>}>
<Dashboard />
<Analytics />
<AdminPanel />
</Suspense>
</div>
);
}
π‘ Two rules for React.lazy
- The lazily imported module must have a default export that is a React component. (There's a named-export workaround below.)
- A lazy component must be rendered inside a
<Suspense>boundary somewhere above it, or React throws.
Splitting a component with a named export
lazy expects a default export. If your module only has named exports, re-map inside the import callback:
// MarkdownEditor.js exports: export function MarkdownEditor() { ... }
const MarkdownEditor = lazy(() =>
import('./MarkdownEditor').then(module => ({ default: module.MarkdownEditor }))
);
How a Chunk Loads
It helps to picture the exact sequence when a lazy component first appears. React tries to render it, discovers the code isn't here yet, "suspends," shows your fallback, kicks off the network request, and re-renders with the real component once the chunk arrives.
The first time a user hits that screen, they wait for the fetch (usually milliseconds on a decent connection). On later visits the chunk is cached, so it's instant. The tradeoff β a tiny delay the first time in exchange for a much smaller initial download β is almost always worth it for screens users don't visit immediately.
Route-Based Splitting: The Big Win
If you do only one kind of code splitting, do this one. Routes are natural split points: a user on /dashboard has no need for the /admin code yet. Lazy-load each route's top-level component and the router fetches it exactly when the user navigates there.
import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
const Home = lazy(() => import('./pages/Home'));
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Profile = lazy(() => import('./pages/Profile'));
const AdminPanel = lazy(() => import('./pages/AdminPanel'));
function LoadingFallback() {
return (
<div className="loading-container" role="status" aria-live="polite">
<div className="loading-spinner" />
<p>Loadingβ¦</p>
</div>
);
}
function App() {
return (
<BrowserRouter>
<Navigation /> {/* stays in the main chunk β visible on every page */}
<Suspense fallback={<LoadingFallback />}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/profile" element={<Profile />} />
<Route path="/admin" element={<AdminPanel />} />
</Routes>
</Suspense>
</BrowserRouter>
);
}
Now the homepage bundle no longer contains the admin panel, the dashboard charts, or the profile editor. Each arrives only when its route is visited. Placing the Suspense just inside the router but outside the Routes means one fallback covers every route transition, while shared chrome like Navigation stays put and never flashes a spinner.
β Rule of thumb: split at route boundaries first
Route-based splitting gives the biggest bundle reduction for the least complexity and the fewest visible loading states. Start there. Reach for finer-grained splitting only when profiling shows a specific heavy component dragging down a route that's otherwise light.
On-Demand Components
Beyond routes, split heavy components that only appear after an interaction β a chart behind a button, a rich text editor in a modal, a map that opens on demand. Combine the lazy component with conditional rendering so its chunk isn't even requested until the user asks for it.
import { lazy, Suspense, useState } from 'react';
const HeavyChart = lazy(() => import('./components/HeavyChart'));
const RichTextEditor = lazy(() => import('./components/RichTextEditor'));
function Dashboard() {
const [showChart, setShowChart] = useState(false);
const [showEditor, setShowEditor] = useState(false);
return (
<div className="dashboard">
<button onClick={() => setShowChart(true)}>Show analytics chart</button>
<button onClick={() => setShowEditor(true)}>Open editor</button>
{/* The chart chunk downloads only after the first click */}
{showChart && (
<Suspense fallback={<div>Loading chartβ¦</div>}>
<HeavyChart onClose={() => setShowChart(false)} />
</Suspense>
)}
{showEditor && (
<Suspense fallback={<div>Loading editorβ¦</div>}>
<RichTextEditor onClose={() => setShowEditor(false)} />
</Suspense>
)}
</div>
);
}
A charting or editor library can be hundreds of kilobytes. Keeping it out of the initial bundle β and out of the download entirely for users who never open it β is exactly the kind of targeted win on-demand splitting delivers.
Error Boundaries: Handling Failed Loads
A dynamic import is a network request, and network requests fail β flaky Wi-Fi, a deploy that invalidated an old chunk URL, a tunnel. When a chunk fails to load, the lazy component throws. Suspense handles the loading state; an error boundary handles the failure state. Use both together.
import { Component } from 'react';
class ErrorBoundary extends Component {
state = { hasError: false, error: null };
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
componentDidCatch(error, info) {
console.error('Chunk failed to load:', error, info);
}
render() {
if (this.state.hasError) {
return (
<div className="error-fallback" role="alert">
<h2>Couldn't load this section</h2>
<button onClick={() => this.setState({ hasError: false, error: null })}>
Try again
</button>
</div>
);
}
return this.props.children;
}
}
// Reusable wrapper: error boundary OUTSIDE, Suspense INSIDE
function LazyBoundary({ children, fallback }) {
return (
<ErrorBoundary>
<Suspense fallback={fallback ?? <LoadingSpinner />}>
{children}
</Suspense>
</ErrorBoundary>
);
}
// Usage
<LazyBoundary fallback={<WidgetSkeleton />}>
<AnalyticsWidget />
</LazyBoundary>
β οΈ Order matters: error boundary outside, Suspense inside
The error boundary must sit above the Suspense so it can catch the throw when the import rejects. If you nest them the other way, the boundary is inside the suspended tree and never gets the chance to render its fallback. Error boundaries are still class components β this is one of the few places you'll write one in modern React.
Preloading: Making Lazy Feel Instant
Lazy loading has one downside: the first visit to a screen waits for the fetch. You can erase that wait by preloading the chunk slightly before the user needs it β on hover, on focus, or when their intent is obvious. The trick is that calling the same dynamic import() just starts (and caches) the fetch; rendering the component later reuses it.
const AdminPanel = lazy(() => import('./AdminPanel'));
// Kick off the download early β the browser caches the module Promise
const preloadAdmin = () => { import('./AdminPanel'); };
function Navigation({ user }) {
return (
<nav>
<Link to="/">Home</Link>
{user.isAdmin && (
<Link
to="/admin"
onMouseEnter={preloadAdmin} {/* preload on hover⦠*/}
onFocus={preloadAdmin} {/* β¦and for keyboard users */}
>
Admin
</Link>
)}
</nav>
);
}
By the time the click registers, the chunk is often already in cache and the panel appears instantly. You can preload on any signal of intent β adding an item to a cart is a strong hint the user will head to checkout:
function CartButton({ itemCount, onClick }) {
return (
<button
onClick={onClick}
onMouseEnter={() => { if (itemCount > 0) import('./pages/Checkout'); }}
>
Cart ({itemCount})
</button>
);
}
π‘ Bundler hints (Vite / webpack)
Bundlers understand magic-comment hints inside a dynamic import. /* webpackPrefetch: true */ tells the browser to fetch the chunk during idle time; /* webpackPreload: true */ fetches it alongside the parent chunk. Vite exposes similar behavior via <link rel="modulepreload">. These are optional polish on top of the React.lazy foundation.
Practice & Quiz
ποΈ Exercise 1: Convert eager routes to lazy routes
Goal: Take this eagerly-imported router and split every page into its own chunk with a single shared fallback.
import Home from './pages/Home';
import Reports from './pages/Reports';
import Settings from './pages/Settings';
import { Routes, Route } from 'react-router-dom';
function App() {
return (
<Routes>
<Route path="/" element={<Home />} />
<Route path="/reports" element={<Reports />} />
<Route path="/settings" element={<Settings />} />
</Routes>
);
}
π‘ Hint
Replace each static import with lazy(() => import(...)), then wrap the whole <Routes> in one <Suspense> with a fallback.
β Solution
import { lazy, Suspense } from 'react';
import { Routes, Route } from 'react-router-dom';
const Home = lazy(() => import('./pages/Home'));
const Reports = lazy(() => import('./pages/Reports'));
const Settings = lazy(() => import('./pages/Settings'));
function App() {
return (
<Suspense fallback={<div role="status">Loadingβ¦</div>}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/reports" element={<Reports />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
);
}
ποΈ Exercise 2: Preload on hover
Goal: A settings page is lazy-loaded, but the transition feels laggy. Preload its chunk when the user hovers the link, so the page is usually ready by the time they click.
β Solution
const Settings = lazy(() => import('./pages/Settings'));
const preloadSettings = () => { import('./pages/Settings'); };
function Nav() {
return (
<Link
to="/settings"
onMouseEnter={preloadSettings}
onFocus={preloadSettings}
>
Settings
</Link>
);
}
// The dynamic import() caches the module, so the later render reuses it.
π― Quick Quiz
Question 1: What does React.lazy(() => import('./X')) require in the surrounding tree?
Question 2: Which type of code splitting usually gives the biggest win for the least effort?
Question 3: A lazy chunk fails to download. What handles that failure gracefully?
Best Practices & Pitfalls
β Do
- Split at route boundaries first β the highest-value, lowest-effort win
- Also split heavy, rarely-used components (charts, editors, maps) behind interactions
- Give every
Suspensea meaningful fallback β a skeleton that matches the layout beats a bare "Loadingβ¦" - Wrap lazy trees in an error boundary so a failed chunk doesn't blank the screen
- Preload on hover/focus/intent to hide the first-load latency
- Watch bundle sizes with your bundler's analyzer (e.g.
rollup-plugin-visualizer,webpack-bundle-analyzer)
β Don't
- Split tiny components β the extra network requests and loading flashes cost more than they save
- Forget the
Suspenseboundary β a lazy component without one throws at render - Put the error boundary inside the
Suspenseβ it must be outside to catch load errors - Lazy-load content that's visible immediately above the fold β you'd just delay the first paint you care about
- Rely on a lazy module having only named exports without re-mapping to a
default
β οΈ Layout shift from fallbacks
A fallback that's a different size from the real component makes the page jump when the chunk arrives. Size your skeletons to match the final content β reserve the space up front so loading is smooth, not jarring.
Summary
π Key Takeaways
- A large initial bundle slows first load; code splitting breaks it into on-demand chunks
- Dynamic
import()is the foundation;React.lazyturns it into a component - Every lazy component needs a
Suspenseboundary with afallback - Route-based splitting is the biggest win for the least effort β do it first
- Wrap lazy trees in an error boundary (outside the Suspense) to survive failed loads
- Preload on hover or intent to make lazy loading feel instant
π Additional Resources
- react.dev β
lazy - react.dev β
<Suspense> - react.dev β Error boundaries
- React Router β Lazy route loading
π What's Next?
Your app is now fast to render and fast to load. Time to make sure it stays correct as it grows: the next lesson begins testing your components with React Testing Library basics β writing tests that check what users actually see and do.
π¦ Shipped smart!
You can now hand users only the code they need, when they need it β and gracefully cover both the loading and the failure paths.