Skip to main content

πŸ“¦ 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.lazy and dynamic import()
  • 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.

flowchart TD A[All app code] --> B[Bundler] B --> C[One large bundle] C --> D[User downloads
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.

flowchart TD A[All app code] --> B[Bundler with
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 Suspense boundary around a lazy component The App renders an eager Header and Content immediately, while a dashed Suspense boundary wraps a lazily loaded component and shows a fallback until its chunk arrives. App Component Header (eager) Content (eager) Suspense boundary Lazy component shows fallback until the chunk loads
Figure 1 β€” Eager components render immediately; the lazy component sits inside a Suspense boundary that displays a fallback until its chunk arrives.

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>
  );
}
flowchart TD A[User navigates] --> B[Main chunk loads] B --> C[Router matches path] C --> D{Which route?} D -->|/| E[Fetch Home chunk] D -->|/dashboard| F[Fetch Dashboard chunk] D -->|/products| G[Fetch Products chunk] D -->|/analytics| H[Fetch Analytics chunk]

πŸ’‘ 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:

ToolWhat it tells you
Bundle visualizer (rollup-plugin-visualizer, webpack-bundle-analyzer)What's inside each chunk and how big it is
LighthouseLoad-performance scores and metrics
DevTools β†’ NetworkWhich chunks load, when, and how large over the wire
DevTools β†’ PerformanceMain-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:

  1. Keep ProductDetails eager (it's critical, above the fold).
  2. Lazy-load SizeChart, ProductReviews, and RecommendedProducts.
  3. Give each a Suspense fallback, 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 + dynamic import() defers a component into its own chunk; Suspense shows 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

πŸš€ 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.