React’s lazy() and Suspense allow you to split your code into smaller chunks that are loaded on demand, improving initial load times.

Here’s a simple example of how to implement code splitting with React.lazy() and Suspense:

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

// Dynamically import the component
const OtherComponent = lazy(() => import('./OtherComponent'));

function MyComponent() {
  return (
    <div>
      <Suspense fallback={<div>Loading...</div>}>
        <OtherComponent />
      </Suspense>
    </div>
  );
}

export default MyComponent;

In this setup, OtherComponent will only be loaded when MyComponent is rendered. The Suspense boundary provides a fallback UI (<div>Loading...</div>) to display while the OtherComponent is being fetched.

This approach is incredibly powerful for optimizing application performance, especially for large applications. Instead of shipping one massive JavaScript bundle, you can break it down into smaller, manageable pieces. When a user navigates to a new section of your app, only the code for that specific section is downloaded. This drastically reduces the amount of JavaScript the browser needs to parse and execute on initial load, leading to faster Time to Interactive (TTI) and a smoother user experience.

Let’s dive deeper into how this works and the knobs you can turn.

The Problem Code Splitting Solves

Before code splitting, a typical React application would bundle all its JavaScript code into a single file. As your application grows, this bundle can become enormous. Browsers have to download, parse, and execute this entire bundle before the user can interact with the page, even if they only see a small part of the application initially. This leads to:

  • Slow initial load times: Users wait longer to see anything on the screen.
  • High memory usage: The browser has to hold onto a lot of code.
  • Wasted bandwidth: Users download code they might never use.

Code splitting, specifically with dynamic import(), addresses this by allowing you to tell the bundler (like Webpack or Parcel) to create separate "chunks" of code. These chunks are then loaded asynchronously only when they are needed.

How React.lazy() and Suspense Work Together

React.lazy() is a function that lets you render a dynamically imported component as a regular component. It takes a function that must call a dynamic import(). The dynamic import() returns a Promise that resolves to a module with a default export containing the React component.

// OtherComponent.js
export default function OtherComponent() {
  return <div>This is the other component!</div>;
}

When React.lazy() encounters a component that hasn’t been loaded yet, it throws a Promise. This is where Suspense comes in. Suspense lets you specify a loading indicator (the fallback prop) that should be rendered while the lazy component is being fetched and loaded. It "listens" for these Promises thrown by lazy components. When the Promise resolves, Suspense re-renders its children with the loaded component.

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

// The dynamic import is inside React.lazy
const LazyComponent = lazy(() => import('./MyLazyComponent'));

function App() {
  return (
    <div>
      <h1>My App</h1>
      <Suspense fallback={<div>Loading component...</div>}>
        {/* LazyComponent will be loaded here */}
        <LazyComponent />
      </Suspense>
    </div>
  );
}

You can nest Suspense boundaries. If multiple lazy components are siblings within the same Suspense boundary, only one fallback will be shown. If they are in different boundaries, each boundary can show its own fallback.

Real-World Application Structure

In a production app, you’ll typically use React.lazy() for routes or components that are not immediately visible or critical for the initial view.

Example: Route-based code splitting

Using a routing library like react-router-dom:

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

const HomePage = lazy(() => import('./pages/HomePage'));
const AboutPage = lazy(() => import('./pages/AboutPage'));
const ContactPage = lazy(() => import('./pages/ContactPage'));

function App() {
  return (
    <Router>
      <nav>
        <Link to="/">Home</Link>
        <Link to="/about">About</Link>
        <Link to="/contact">Contact</Link>
      </nav>
      <Suspense fallback={<div>Loading page...</div>}>
        <Routes>
          <Route path="/" element={<HomePage />} />
          <Route path="/about" element={<AboutPage />} />
          <Route path="/contact" element={<ContactPage />} />
        </Routes>
      </Suspense>
    </Router>
  );
}

Here, each page component is lazy-loaded. When a user clicks a link, the corresponding page’s JavaScript chunk is fetched and displayed within its Suspense boundary.

Configuration with Bundlers

Modern bundlers like Webpack and Parcel handle dynamic import() out-of-the-box. You don’t usually need to configure much for basic code splitting. Webpack, for instance, will automatically create separate output files (chunks) for each dynamic import.

For example, if your entry point is index.js and it dynamically imports AboutPage.js, Webpack might generate:

  • main.js (your initial bundle)
  • 1.js (the chunk for AboutPage.js)

You can inspect your bundler’s output to see these chunks. In Webpack, you can configure output.chunkFilename to control the naming of these dynamic chunks.

The Nuance of Error Handling

While Suspense handles loading states gracefully, it doesn’t inherently handle errors during the component loading process. If the dynamic import fails (e.g., network error, server issue), the Promise will reject. By default, this will cause an unhandled rejection and likely break your application.

To handle these errors, you should wrap your lazy components (or their Suspense boundaries) with an Error Boundary.

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

// Assume ErrorBoundary is a component that catches errors
// and renders a fallback UI.
import ErrorBoundary from './ErrorBoundary';

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

function App() {
  return (
    <div>
      <ErrorBoundary fallback={<div>Something went wrong loading the component.</div>}>
        <Suspense fallback={<div>Loading...</div>}>
          <MyLazyComponent />
        </Suspense>
      </ErrorBoundary>
    </div>
  );
}

The ErrorBoundary will catch any errors thrown during the rendering of MyLazyComponent, including those from the dynamic import or the component itself, and display its fallback UI.

Performance Considerations and Best Practices

  1. Granularity: Don’t split too aggressively. Chunks have overhead. Too many small chunks can sometimes be worse than one larger one due to increased HTTP requests. Aim for meaningful chunks, like entire routes or large feature modules.
  2. Preloading/Prefetching: For critical routes or components that a user is highly likely to navigate to next, you can use import() hints (like /* webpackPrefetch: true */ or /* webpackPreload: true */ in Webpack) to tell the bundler to download these chunks in the background. preload is generally for critical resources for the current page, while prefetch is for resources likely needed for future navigation.
  3. Server-Side Rendering (SSR): Integrating lazy() and Suspense with SSR requires careful setup. You’ll need to ensure that the server can render the initial HTML and that the client can hydrate correctly, picking up the lazy-loaded components. React 18 introduced improvements for concurrent rendering and suspense in SSR.
  4. Bundle Analysis: Use tools like webpack-bundle-analyzer to visualize your bundle sizes and identify which components are contributing most to your chunks. This helps you make informed decisions about where to apply code splitting.

The truly surprising part about Suspense is its potential beyond just loading states. It’s designed as a more general mechanism for coordinating asynchronous operations in React, including data fetching. While React.lazy is the most common use case today, future libraries and React features might leverage Suspense for things like data fetching waterfalls, allowing you to declare "I need this data" and have React manage the loading and error states for you, without explicit useEffect or loading spinners in every component.

Understanding how these code chunks are named and managed by your bundler is the next key to optimizing delivery.

Want structured learning?

Take the full React course →