React.lazy & Suspense

Difficulty: Intermediate

Question

How do React.lazy and Suspense work? How do you implement code splitting in a React application?

Answer

Code splitting with React.lazy and Suspense is one of those performance topics that has a huge impact on real-world user experience but doesn't get enough attention in tutorials. Let me explain why it matters, how it works, and the practical details that make it production-ready.

Let's start with the problem. When you build a React app with a bundler like Vite or Webpack, by default, ALL of your JavaScript gets bundled into one (or a few) files. Every page, every component, every library. Your user visits the homepage, and their browser downloads the code for the homepage, the dashboard, the settings page, the admin panel, and that giant chart library you use on one page. The user might never visit 90% of those pages, but they paid the download cost upfront. On a fast connection, this might not matter. On a 3G mobile connection in a developing country, your app takes 15 seconds to become interactive. That's a real problem.

Code splitting solves this by breaking your bundle into smaller chunks that are loaded on demand. The user visits the homepage, and they only download the homepage code. When they navigate to the dashboard, that chunk is fetched then. The result is a dramatically faster initial load.

React.lazy is how you tell React to split a component into its own chunk. Instead of import Dashboard from './pages/Dashboard', you write const Dashboard = lazy(() => import('./pages/Dashboard')). That dynamic import() tells the bundler to create a separate chunk for Dashboard and everything it imports. At runtime, the first time Dashboard needs to render, React triggers the dynamic import, downloads the chunk, and renders the component.

But there's a gap between 'React starts downloading the chunk' and 'the chunk arrives and the component renders'. What do you show the user during that gap? That's where Suspense comes in. Suspense is a component that wraps lazy components and shows a fallback UI (like a spinner or skeleton) while the chunk is loading. Once the chunk arrives, Suspense seamlessly swaps the fallback for the real component.

The most common and impactful code splitting strategy is route-based splitting. Each page of your app becomes a separate chunk. This makes intuitive sense: when a user visits /dashboard, they need the dashboard code; they don't need the settings or admin code. In a React Router setup, you lazy-import each page component and wrap your Routes in a Suspense boundary. The bundler handles the rest.

But route-based splitting isn't the only option. You can also split by feature within a page. Say your dashboard has a lightweight overview section that loads instantly, but also has a heavy analytics chart that uses a massive charting library. You can lazy-load just the chart component. The overview renders immediately, and the chart loads in the background with its own Suspense spinner. This gives users instant access to the light content while heavier features stream in.

Here's a pro tip that separates good implementations from great ones: preloading. The default lazy loading experience is: user clicks a link, spinner appears, chunk downloads, component renders. That spinner, even if it's only there for 200ms, feels janky. You can eliminate it by preloading chunks before the user needs them. When the user hovers over a navigation link to 'Settings', you trigger import('./pages/Settings') proactively. By the time they click, the chunk is already cached in the browser, and the component renders instantly with no spinner. The preload function is literally just calling the import function. Dynamic imports are cached, so calling it again doesn't re-download the chunk.

Some practical details you need to know:

React.lazy only works with default exports. If your component uses a named export (export function Calculator), you need a wrapper: lazy(() => import('./mathUtils').then(module => ({ default: module.Calculator }))). This maps the named export to a default export that React.lazy expects.

Error handling is crucial. What happens when the chunk fails to download? Maybe the user's connection dropped, or you deployed a new version and the old chunk URLs are no longer valid (this is very common and called 'chunk loading errors'). Without handling, the Suspense boundary just hangs forever or throws an uncaught promise rejection. You should combine Suspense with an Error Boundary: Suspense handles the loading state, and the Error Boundary catches failures. You can also add a retry mechanism that catches the import error and retries a few times with a delay, which handles transient network issues gracefully.

The chunk loading error after deployment is so common it deserves special attention. When you deploy a new version of your app, the old chunk file names (which include content hashes) no longer exist on the server. If a user had the old version open in their browser and navigates to a lazy-loaded route, the browser tries to fetch a chunk URL that returns 404. The common solution is to catch this error and force a page reload, which fetches the new HTML with updated chunk URLs.

Suspense boundaries can be nested. You can have a page-level Suspense for the route transition and a component-level Suspense for a heavy feature within the page. The inner Suspense shows its own fallback independently of the outer one. This gives you fine-grained control over loading states.

In React 18 and beyond, Suspense has grown beyond just code splitting. It now supports data fetching (when paired with compatible frameworks like Next.js or libraries like Relay) and streaming server rendering. The idea is that Suspense becomes the universal way to handle any asynchronous operation in React: code loading, data fetching, image loading, etc. The fallback UI is shown while any child is 'suspended', regardless of the reason.

For interviews, know the difference between route-based and feature-based splitting, mention the preloading technique, and explain how Suspense and Error Boundaries work together (Suspense for loading, Error Boundary for failure). Those three points show depth beyond the basic usage.

Code examples

Route-Based Code Splitting

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

// Each page is a separate chunk loaded on demand
const Home = lazy(() => import('./pages/Home'));
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
const AdminPanel = lazy(() => import('./pages/AdminPanel'));

function LoadingSpinner() {
  return <div className="spinner">Loading...</div>;
}

function App() {
  return (
    <BrowserRouter>
      <Suspense fallback={<LoadingSpinner />}>
        <Routes>
          <Route path="/" element={<Home />} />
          <Route path="/dashboard" element={<Dashboard />} />
          <Route path="/settings" element={<Settings />} />
          <Route path="/admin" element={<AdminPanel />} />
        </Routes>
      </Suspense>
    </BrowserRouter>
  );
}

// Bundle output:
// main.js      - App shell + routing
// Home.chunk.js     - loaded when visiting /
// Dashboard.chunk.js - loaded when visiting /dashboard
// Settings.chunk.js  - loaded when visiting /settings
// AdminPanel.chunk.js - loaded when visiting /admin

Each lazy() call creates a split point. The browser only downloads the chunk for the active route. Suspense shows a spinner while the chunk is being fetched.

Feature-Based Splitting with Preloading

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

// Heavy component - only load when user needs it
const RichTextEditor = lazy(() => import('./components/RichTextEditor'));
const ChartDashboard = lazy(() => import('./components/ChartDashboard'));

// Preload on hover/focus for faster perceived loading
function preloadEditor() {
  import('./components/RichTextEditor');
}

function BlogPost({ post }) {
  const [isEditing, setIsEditing] = useState(false);
  const [showCharts, setShowCharts] = useState(false);

  return (
    <article>
      <h1>{post.title}</h1>

      {isEditing ? (
        <Suspense fallback={<div>Loading editor...</div>}>
          <RichTextEditor content={post.content} />
        </Suspense>
      ) : (
        <div>{post.content}</div>
      )}

      {/* Preload editor when user hovers the edit button */}
      <button
        onMouseEnter={preloadEditor}
        onFocus={preloadEditor}
        onClick={() => setIsEditing(true)}
      >
        Edit Post
      </button>

      {/* Separate Suspense boundary for independent loading */}
      {showCharts && (
        <Suspense fallback={<div>Loading charts...</div>}>
          <ChartDashboard postId={post.id} />
        </Suspense>
      )}
    </article>
  );
}

Split heavy components like editors and charts. Preloading on hover/focus eliminates perceived loading time. Each feature gets its own Suspense boundary.

Named Exports and Error Handling

// React.lazy only supports default exports
// For named exports, create an intermediate module

// mathUtils.js exports: export function Calculator() { ... }
// Wrap it:
const Calculator = lazy(() =>
  import('./mathUtils').then(module => ({
    default: module.Calculator
  }))
);

// Error handling for failed chunk loads
const Dashboard = lazy(() =>
  import('./pages/Dashboard').catch(() => {
    // Network error or chunk failed to load
    return { default: () => <p>Failed to load. Please refresh.</p> };
  })
);

// Retry pattern for flaky networks
function retryImport(importFn, retries = 3) {
  return new Promise((resolve, reject) => {
    importFn()
      .then(resolve)
      .catch((error) => {
        if (retries <= 0) {
          reject(error);
          return;
        }
        setTimeout(() => {
          retryImport(importFn, retries - 1).then(resolve, reject);
        }, 1000);
      });
  });
}

const Settings = lazy(() =>
  retryImport(() => import('./pages/Settings'))
);

// Combined with Error Boundary
<ErrorBoundary fallback={<p>Page failed to load</p>}>
  <Suspense fallback={<Spinner />}>
    <Settings />
  </Suspense>
</ErrorBoundary>

Handle named exports by mapping them to default. Add retry logic for network failures. Combine Suspense with Error Boundaries for comprehensive error handling.

Key points

Concepts covered

Code Splitting, React.lazy, Suspense, Dynamic Import