Code Splitting and Lazy Loading in React

Advanced techniques for optimizing React application performance

Introduction to Bundle Optimization

Modern web applications often grow to contain thousands of components, libraries, and assets. Without proper optimization, users must download the entire application before they can interact with it, leading to poor user experiences, especially on slower networks or less powerful devices.

Imagine going to a grocery store and being forced to buy all items in the store just to get milk! That's what happens when we serve monolithic JavaScript bundles to our users - they download code for pages they may never visit.

graph TD A[Application Code] --> B[Webpack/Bundler] B --> C[Single Large Bundle] C --> D[User Downloads Everything] D --> E[Slow Initial Load]

What is Code Splitting?

Code splitting is a technique that breaks your application code into smaller chunks (or bundles) that can be loaded on demand or in parallel. Instead of loading the entire application upfront, code splitting allows you to load only what's necessary when it's necessary.

Real-world analogy: Think of code splitting like reading a book chapter by chapter instead of memorizing the entire book before you can start reading. You only need to process what's relevant to your current reading session.

graph TD A[Application Code] --> B[Webpack/Bundler with Code Splitting] B --> C[Main Bundle] B --> D[Feature 1 Bundle] B --> E[Feature 2 Bundle] B --> F[Feature 3 Bundle] C --> G[Initial Load] D & E & F --> H[Load on Demand]

Benefits of Code Splitting

Lazy Loading with React.lazy()

React.lazy() is a function that lets you render a dynamic import as a regular component. It makes code splitting seamless in React by making components load only when they're needed.

Before React.lazy(), you would load all your components eagerly, regardless of whether they were visible:


// Without lazy loading
import ExpensiveComponent from './ExpensiveComponent';

function App() {
  return (
    <div>
      <Header />
      <MainContent />
      {shouldShowExpensiveComponent && <ExpensiveComponent />}
      <Footer />
    </div>
  );
}
            

With React.lazy(), the component is only loaded when it's rendered for the first time:


// With lazy loading
import React, { lazy, Suspense } from 'react';

const ExpensiveComponent = lazy(() => import('./ExpensiveComponent'));

function App() {
  return (
    <div>
      <Header />
      <MainContent />
      {shouldShowExpensiveComponent && (
        <Suspense fallback={<div>Loading...</div>}>
          <ExpensiveComponent />
        </Suspense>
      )}
      <Footer />
    </div>
  );
}
            

Note: React.lazy currently only supports default exports. If you need to import a named export, you'll need an intermediate module that re-exports it as the default.

The Suspense Component

Suspense is a React component that lets you "wait" for some code to load and declaratively specify a loading state (like a spinner) while waiting.

Think of Suspense as a traffic controller that makes your application feel smooth even when parts of it are still loading. It's like a restaurant host who seats you and gives you drinks while your table is being prepared.

App Component Header (Eager) Content (Eager) Suspense Boundary Lazy Component Shows fallback while lazy component loads

Suspense works with any lazy-loaded component, not just those loaded with React.lazy. In the future, it will also support other asynchronous operations like data fetching.

Multiple Lazy Components in One Suspense

You can wrap multiple lazy components with a single Suspense:


import React, { lazy, Suspense } from 'react';

const LazyComponent1 = lazy(() => import('./LazyComponent1'));
const LazyComponent2 = lazy(() => import('./LazyComponent2'));

function App() {
  return (
    <div>
      <Suspense fallback={<div>Loading...</div>}>
        <LazyComponent1 />
        <LazyComponent2 />
      </Suspense>
    </div>
  );
}
            

In this case, both components will be requested in parallel, but the fallback will be shown until both components are ready.

Strategic Code Splitting

Now that we understand the "how" of code splitting, let's talk about the "where" - strategic places to implement code splitting for maximum benefit.

Route-Based Code Splitting

The most common and effective code splitting strategy is by routes. Each page or major view in your application becomes its own bundle:


import React, { lazy, Suspense } from 'react';
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';

// Regular import for components needed right away
import Header from './Header';
import Footer from './Footer';

// Lazy load route components
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 (
    <Router>
      <Header />
      <Suspense fallback={<div className="loading">Loading...</div>}>
        <Switch>
          <Route exact path="/" component={Home} />
          <Route path="/dashboard" component={Dashboard} />
          <Route path="/products" component={Products} />
          <Route path="/analytics" component={Analytics} />
        </Switch>
      </Suspense>
      <Footer />
    </Router>
  );
}
            
flowchart TD A[User Navigates to URL] --> B[Main Bundle Loads] B --> C[Router Logic Runs] C --> D{Which Route?} D -->|/ route| E[Load Home Bundle] D -->|/dashboard route| F[Load Dashboard Bundle] D -->|/products route| G[Load Products Bundle] D -->|/analytics route| H[Load Analytics Bundle]

Component-Based Code Splitting

You can also split at the component level for large or rarely used features:


// Code splitting a complex chart component
import React, { 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>
  );
}
            

Advanced Patterns: Prefetching

To further improve user experience, you can prefetch components that are likely to be used soon. This is like pre-loading the next episode of a TV show while you're watching the current one.

Route Prefetching

One common strategy is to prefetch routes when a user hovers over a link:


// PrefetchLink.jsx
import React, { useState } from 'react';
import { Link } from 'react-router-dom';

function PrefetchLink({ to, prefetch, children, ...props }) {
  // Keep track of whether we've already prefetched
  const [prefetched, setPrefetched] = useState(false);
  
  const handlePrefetch = () => {
    // Only prefetch once
    if (!prefetched && prefetch) {
      // Execute the prefetch function
      prefetch();
      setPrefetched(true);
    }
  };
  
  return (
    <Link 
      to={to} 
      onMouseEnter={handlePrefetch}
      onFocus={handlePrefetch}
      {...props}
    >
      {children}
    </Link>
  );
}

// Usage example
import React from 'react';

function NavBar() {
  return (
    <nav>
      <PrefetchLink 
        to="/dashboard" 
        prefetch={() => import('./routes/Dashboard')}
      >
        Dashboard
      </PrefetchLink>
    </nav>
  );
}
            

Intelligent Prefetching

You can get more sophisticated with prefetching by considering:


// Example with network and idle considerations
function intelligentPrefetch(importFn) {
  // Don't prefetch on slow connections or data-saver mode
  if (navigator.connection && 
      (navigator.connection.saveData || 
       navigator.connection.effectiveType === 'slow-2g')) {
    return;
  }
  
  // Use requestIdleCallback if available
  if (window.requestIdleCallback) {
    window.requestIdleCallback(() => {
      importFn();
    });
  } else {
    // Fallback to setTimeout
    setTimeout(importFn, 1000);
  }
}

// Usage
<PrefetchLink 
  to="/dashboard" 
  prefetch={() => intelligentPrefetch(() => import('./routes/Dashboard'))}
>
  Dashboard
</PrefetchLink>
            

Error Boundaries with Lazy Loading

When loading code dynamically, network issues or other errors can occur. Error boundaries help catch these problems and provide a better user experience.


// ErrorBoundary.jsx
import React from 'react';

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(error) {
    return { hasError: true };
  }

  componentDidCatch(error, errorInfo) {
    // Log the error to an error reporting service
    console.error("Caught an error:", error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      return this.props.fallback || <h2>Something went wrong loading this component.</h2>;
    }

    return this.props.children;
  }
}

// Usage with lazy loading
import React, { lazy, Suspense } from 'react';
import ErrorBoundary from './ErrorBoundary';

const LazyComponent = lazy(() => import('./LazyComponent'));

function MyComponent() {
  return (
    <ErrorBoundary 
      fallback={<div>Failed to load component. Please try again later.</div>}
    >
      <Suspense fallback={<div>Loading...</div>}>
        <LazyComponent />
      </Suspense>
    </ErrorBoundary>
  );
}
            

Always wrap your lazy-loaded components with error boundaries to handle potential loading failures gracefully.

Code Splitting with Other Build Tools

While we've focused on React's built-in mechanisms, most modern build tools support code splitting:

Webpack (What Create React App Uses)

Webpack supports code splitting via dynamic imports:


// This creates a separate chunk
import('./math').then(math => {
  console.log(math.add(16, 26));
});
            

Vite

Vite has built-in support for dynamic imports and works well with React.lazy():


// Vite automatically optimizes dynamic imports
const Component = defineAsyncComponent(() => import('./Component.vue'))
            

Rollup

Rollup also supports dynamic imports for code splitting:


// This will create a separate chunk in Rollup
import('./module.js').then(({ default: module }) => {
  // Use module
});
            

Real-World Case Studies

Case Study: Airbnb's Performance Improvements

Airbnb reduced their JavaScript bundle size by 170KB by implementing code splitting, resulting in a 50% faster Time to Interactive. They implemented:

Case Study: E-commerce Product Page

A typical e-commerce product page might implement code splitting for:


// E-commerce example
import React, { lazy, Suspense, useState } from 'react';

// Eager load critical components
import ProductDetails from './ProductDetails';
import AddToCartButton from './AddToCartButton';

// Lazy load non-critical components
const ProductReviews = lazy(() => import('./ProductReviews'));
const SizeChart = lazy(() => import('./SizeChart'));
const RecommendedProducts = lazy(() => import('./RecommendedProducts'));

function ProductPage() {
  const [showSizeChart, setShowSizeChart] = useState(false);
  
  return (
    <div>
      <ProductDetails />
      <AddToCartButton />
      
      <button onClick={() => setShowSizeChart(true)}>
        Size Guide
      </button>
      
      {showSizeChart && (
        <Suspense fallback={<div>Loading size chart...</div>}>
          <SizeChart onClose={() => setShowSizeChart(false)} />
        </Suspense>
      )}
      
      <div id="reviews-section">
        <Suspense fallback={<div>Loading reviews...</div>}>
          <ProductReviews />
        </Suspense>
      </div>
      
      <div id="recommendations-section">
        <Suspense fallback={<div>Loading recommendations...</div>}>
          <RecommendedProducts />
        </Suspense>
      </div>
    </div>
  );
}
            

Measuring the Impact

To understand if your code splitting strategy is effective, you need to measure its impact:

Tools for Measuring Bundle Size


// Install webpack-bundle-analyzer
npm install --save-dev webpack-bundle-analyzer

// In webpack.config.js
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;

module.exports = {
  plugins: [
    new BundleAnalyzerPlugin()
  ]
}
            
Bundle Analyzer Visualization

Key Metrics to Track

Best Practices and Common Pitfalls

Best Practices

Common Pitfalls


// Good: Code splitting with named chunks
const ProfilePage = lazy(() => 
  import(/* webpackChunkName: "profile" */ './ProfilePage')
);

// Bad: Too granular code splitting
const ProfileHeader = lazy(() => import('./ProfileHeader'));
const ProfileStats = lazy(() => import('./ProfileStats'));
const ProfileBio = lazy(() => import('./ProfileBio'));
const ProfileActivity = lazy(() => import('./ProfileActivity'));
// Each small component becoming its own chunk creates overhead
            

Practice Exercise

Now it's your turn to practice implementing code splitting in a React application!

Exercise: Optimize an E-commerce Dashboard

For this exercise, you'll take a single-bundle React application and implement code splitting to improve its performance.

Starting Point:

  1. Clone the repository: git clone https://github.com/example/react-dashboard
  2. Install dependencies: npm install
  3. Run the application: npm start
  4. Analyze the current bundle: npm run analyze

Tasks:

  1. Implement route-based code splitting for the main dashboard routes
  2. Add component-level code splitting for the following components:
    • ChartComponent (large visualization library)
    • ProductTable (complex data grid)
    • ImageGallery (heavy media component)
  3. Add proper loading states and error boundaries
  4. Implement a prefetching strategy for one route
  5. Measure the performance impact

Bonus Challenge:

Implement an intelligent prefetching strategy that considers network conditions.

Resources:

The repository includes a README with hints and a solution branch you can check after completing the exercise.

Further Learning Resources

Summary

In this lecture, we've explored code splitting and lazy loading in React applications:

By implementing these techniques, you can significantly improve the performance of your React applications, providing better user experiences especially on mobile devices and slower networks.