π¦ Code Splitting and Lazy Loading
The last two lessons optimized how components re-render. This one attacks a different bottleneck: how much JavaScript users download before they can do anything. Code splitting breaks one giant bundle into on-demand chunks, and React.lazy with Suspense makes loading them feel seamless.
π― Learning Objectives
By the end of this lesson, you will be able to:
- Explain why a monolithic bundle hurts initial load and how code splitting fixes it
- Lazy-load components with
React.lazyand dynamicimport() - Provide loading states with
Suspense, including multiple components in one boundary - Split strategically by route and by component
- Guard lazy loads with an error boundary and improve UX with prefetching
- Measure bundle size and the metrics that prove the win
Estimated Time: 40β50 minutes β’ Difficulty: Intermediate
Hands-on: Convert an eagerly-loaded app into route- and component-split chunks.
In This Lesson
The Monolithic Bundle Problem
As an app grows, a naive build packs every component, library, and helper into one large JavaScript file. The browser must download, parse, and execute all of it before the user can interact β even code for pages they may never visit.
It's like being forced to buy every item in the grocery store just to get a carton of milk. The user pays the cost of the whole app to use a fraction of it, and on a slow phone connection that cost is measured in seconds of blank screen.
everything] D --> E[Slow first paint
& interactivity]
π Key Term: Bundle vs. Chunk
A bundle is the packaged JavaScript your bundler (Vite, webpack, Rollup) produces. A chunk is a separately-loadable piece of that output. Code splitting is simply the practice of producing several small chunks instead of one big bundle, and loading each only when needed.
What Is Code Splitting?
Code splitting breaks your app into chunks that load on demand or in parallel. The browser fetches a small entry chunk to render the first view fast, then pulls in additional chunks as the user navigates or triggers features.
Think of it like reading a book chapter by chapter instead of memorizing the whole thing before you can start. You process only what's relevant to the current moment.
code splitting] B --> C[Main chunk] B --> D[Dashboard chunk] B --> E[Products chunk] B --> F[Analytics chunk] C --> G[Loads immediately] D --> H[Loads on demand] E --> H F --> H
β Why it helps
- Faster initial load β users download only what the first view needs.
- Better caching β changing one feature only invalidates its chunk, not the whole app.
- Less to parse β the browser evaluates less JavaScript up front.
- Sooner interactivity β the main thread frees up faster.
React.lazy and Suspense
The engine under code splitting is the dynamic import() expression, which returns a promise for a module and signals the bundler to emit a separate chunk. React.lazy wraps a dynamic import so you can render the result as an ordinary component.
Without lazy loading, a component is imported statically and always ends up in the main bundle:
// Eager: ExpensiveComponent ships in the main bundle, always
import ExpensiveComponent from './ExpensiveComponent';
function App({ showExpensive }) {
return (
<div>
<Header />
{showExpensive && <ExpensiveComponent />}
</div>
);
}
With lazy and Suspense, the chunk is fetched only the first time the component renders, and Suspense shows a fallback while it loads:
import { lazy, Suspense } from 'react';
const ExpensiveComponent = lazy(() => import('./ExpensiveComponent'));
function App({ showExpensive }) {
return (
<div>
<Header />
{showExpensive && (
<Suspense fallback={<div>Loadingβ¦</div>}>
<ExpensiveComponent />
</Suspense>
)}
</div>
);
}
A single Suspense can wrap several lazy components. They download in parallel, and the fallback shows until all of them are ready:
import { lazy, Suspense } from 'react';
const Panel = lazy(() => import('./Panel'));
const Sidebar = lazy(() => import('./Sidebar'));
function Layout() {
return (
<Suspense fallback={<div>Loadingβ¦</div>}>
<Sidebar />
<Panel />
</Suspense>
);
}
π‘ Default exports and named exports
React.lazy expects the imported module's default export to be the component. To lazy-load a named export, resolve it in the import: lazy(() => import('./mod').then(m => ({ default: m.Widget }))).
Route-Based Splitting
The highest-value split is by route: each page becomes its own chunk, so visiting / never downloads the analytics dashboard. This example uses React Router v6 (current API β Routes and the element prop, not the old Switch/component):
import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import Header from './Header'; // eager: needed on every page
import Footer from './Footer';
const Home = lazy(() => import('./routes/Home'));
const Dashboard = lazy(() => import('./routes/Dashboard'));
const Products = lazy(() => import('./routes/Products'));
const Analytics = lazy(() => import('./routes/Analytics'));
function App() {
return (
<BrowserRouter>
<Header />
<Suspense fallback={<div className="loading">Loadingβ¦</div>}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/products" element={<Products />} />
<Route path="/analytics" element={<Analytics />} />
</Routes>
</Suspense>
<Footer />
</BrowserRouter>
);
}
π‘ Frameworks may do this for you
Meta-frameworks like Next.js and Remix split by route automatically β each page file becomes its own chunk with no manual lazy needed. The manual pattern above is what you use in a plain React + React Router app, and it's exactly what those frameworks automate.
Component-Based Splitting
Beyond routes, split heavy or rarely-used components so their weight never touches users who don't open them. Good candidates:
- Modals and dialogs not shown to every user (a size chart, a confirmation flow)
- Heavy widgets β rich text editors, maps, charting libraries
- Feature-gated areas β admin panels, settings pages
- Third-party integrations β payment forms, chat widgets
import { lazy, Suspense, useState } from 'react';
const ComplexChart = lazy(() => import('./ComplexChart'));
function Dashboard() {
const [showChart, setShowChart] = useState(false);
return (
<div>
<h1>Dashboard</h1>
<button onClick={() => setShowChart(true)}>Show Performance Chart</button>
{showChart && (
<Suspense fallback={<div>Loading chartβ¦</div>}>
<ComplexChart />
</Suspense>
)}
</div>
);
}
The chart's library (often hundreds of kilobytes) only downloads when a user actually clicks the button β most visitors never pay for it.
Error Boundaries for Lazy Loads
A dynamic import can fail β a flaky network, a deploy that removed the old chunk. Suspense handles the loading state, but not errors. Wrap lazy components in an error boundary so a failed chunk shows a graceful message instead of crashing the app.
import { Component } from 'react';
class ErrorBoundary extends Component {
state = { hasError: false };
static getDerivedStateFromError() {
return { hasError: true };
}
componentDidCatch(error, info) {
console.error('Lazy load failed:', error, info);
// report to your error-tracking service here
}
render() {
if (this.state.hasError) {
return this.props.fallback ?? <p>Something went wrong loading this section.</p>;
}
return this.props.children;
}
}
Compose the boundary outside the Suspense so it can catch both load failures and render errors:
import { lazy, Suspense } from 'react';
import ErrorBoundary from './ErrorBoundary';
const Reviews = lazy(() => import('./Reviews'));
function ReviewsSection() {
return (
<ErrorBoundary fallback={<div>Couldn't load reviews. Please retry later.</div>}>
<Suspense fallback={<div>Loading reviewsβ¦</div>}>
<Reviews />
</Suspense>
</ErrorBoundary>
);
}
β οΈ Error boundaries are still class components
As of React 19 there is no Hook equivalent for getDerivedStateFromError/componentDidCatch, so error boundaries remain one of the few places you write a class β or use a library like react-error-boundary that wraps one for you. Everything else in your app can stay function components.
Prefetching: Loading Just Before It's Needed
Lazy loading has one downside: the first time a user opens a lazy view, they wait for the chunk. Prefetching hides that delay by fetching the chunk ahead of the click β for example when the user hovers a link β so it's already cached when they navigate. It's like pre-loading the next episode while the current one plays.
import { useRef } from 'react';
import { Link } from 'react-router-dom';
// Prefetches the target chunk on hover/focus, once.
function PrefetchLink({ to, load, children, ...props }) {
const done = useRef(false);
const prefetch = () => {
if (!done.current) {
load(); // triggers the dynamic import β browser caches the chunk
done.current = true;
}
};
return (
<Link to={to} onMouseEnter={prefetch} onFocus={prefetch} {...props}>
{children}
</Link>
);
}
// Usage
<PrefetchLink to="/dashboard" load={() => import('./routes/Dashboard')}>
Dashboard
</PrefetchLink>
Be considerate about when you prefetch. Respect the user's connection and data-saver preference, and prefer browser idle time so prefetching never competes with what the user is doing right now:
function smartPrefetch(load) {
const conn = navigator.connection;
// Skip on data-saver or very slow connections.
if (conn && (conn.saveData || conn.effectiveType === 'slow-2g')) return;
if ('requestIdleCallback' in window) {
requestIdleCallback(() => load());
} else {
setTimeout(load, 1000);
}
}
Measuring the Impact
Splitting is only worthwhile if it moves real metrics. Measure before and after with the right tools:
| Tool | What it tells you |
|---|---|
Bundle visualizer (rollup-plugin-visualizer, webpack-bundle-analyzer) | What's inside each chunk and how big it is |
| Lighthouse | Load-performance scores and metrics |
| DevTools β Network | Which chunks load, when, and how large over the wire |
| DevTools β Performance | Main-thread parse/execute bottlenecks |
For a Vite project, add the visualizer to your build:
// vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { visualizer } from 'rollup-plugin-visualizer';
export default defineConfig({
plugins: [react(), visualizer({ open: true })],
});
π Metrics that matter
FCP (First Contentful Paint) β when the user first sees content. TTI (Time to Interactive) β when the page reliably responds to input. Initial bundle size β bytes needed for the first render. Route splitting typically improves all three, most dramatically on slow devices and networks.
Hands-on Exercise
ποΈ Split an Eagerly-Loaded Product Page
Objective: The page below imports everything eagerly, so the size chart, reviews, and recommendation libraries all ship even to users who never open them. Split them so they load on demand, with proper loading and error states.
import ProductDetails from './ProductDetails';
import SizeChart from './SizeChart'; // heavy, rarely opened
import ProductReviews from './ProductReviews'; // below the fold
import RecommendedProducts from './RecommendedProducts';
import { useState } from 'react';
function ProductPage() {
const [showSizeChart, setShowSizeChart] = useState(false);
return (
<div>
<ProductDetails />
<button onClick={() => setShowSizeChart(true)}>Size Guide</button>
{showSizeChart && <SizeChart onClose={() => setShowSizeChart(false)} />}
<ProductReviews />
<RecommendedProducts />
</div>
);
}
Your tasks:
- Keep
ProductDetailseager (it's critical, above the fold). - Lazy-load
SizeChart,ProductReviews, andRecommendedProducts. - Give each a
Suspensefallback, and wrap the reviews in an error boundary.
π‘ Hint
Replace each static import with lazy(() => import('./X')). The size chart only mounts on a click, so its Suspense goes inside the conditional. Reviews load below the fold β a good place for both Suspense and an ErrorBoundary.
β Solution
import { lazy, Suspense, useState } from 'react';
import ProductDetails from './ProductDetails'; // stays eager
import ErrorBoundary from './ErrorBoundary';
const SizeChart = lazy(() => import('./SizeChart'));
const ProductReviews = lazy(() => import('./ProductReviews'));
const RecommendedProducts = lazy(() => import('./RecommendedProducts'));
function ProductPage() {
const [showSizeChart, setShowSizeChart] = useState(false);
return (
<div>
<ProductDetails />
<button onClick={() => setShowSizeChart(true)}>Size Guide</button>
{showSizeChart && (
<Suspense fallback={<div>Loading size chartβ¦</div>}>
<SizeChart onClose={() => setShowSizeChart(false)} />
</Suspense>
)}
<ErrorBoundary fallback={<div>Couldn't load reviews.</div>}>
<Suspense fallback={<div>Loading reviewsβ¦</div>}>
<ProductReviews />
</Suspense>
</ErrorBoundary>
<Suspense fallback={<div>Loading recommendationsβ¦</div>}>
<RecommendedProducts />
</Suspense>
</div>
);
}
Now the initial bundle carries only ProductDetails. The size chart's library downloads only when a shopper opens it, and the below-the-fold sections stream in with their own fallbacks β with reviews protected against a failed fetch.
π― Quick Quiz
Question 1: What does React.lazy(() => import('./Chart')) accomplish?
Question 2: Why wrap a lazy component in both Suspense and an error boundary?
Question 3: Which is the single most effective place to start code splitting?
Summary & Quiz
π Key Takeaways
- A single monolithic bundle forces users to download code they may never use; code splitting produces smaller, on-demand chunks.
React.lazy+ dynamicimport()defers a component into its own chunk;Suspenseshows a fallback while it loads.- Split by route first (biggest win), then by component for heavy or rarely-used features.
- Wrap lazy loads in an error boundary so a failed chunk degrades gracefully.
- Prefetch likely-next chunks on hover/idle β respecting connection and data-saver.
- Don't over-split; each chunk has overhead. Measure FCP, TTI, and bundle size to confirm the gain.
π Further Reading
- React docs β
lazy - React docs β
Suspense - web.dev β Reduce JavaScript payloads with code splitting
- React Router β lazy route loading
π What's Next?
You've now covered the three pillars of React performance β memoized rendering, stable references, and lean bundles. Next you'll put the whole module together in the weekend project, building and optimizing an advanced React app end to end.
π Module nearly complete!
Your apps can now render efficiently and load fast. Time to build something real.