Dung (Donny) Nguyen

Senior Software Engineer

Code Splitting & Suspense

What is Code Splitting?

Code splitting is a performance optimization technique that breaks your application’s JavaScript bundle into smaller chunks that are loaded on demand, rather than shipping one large bundle to the browser up front. This reduces the initial load time, because users only download the code they actually need for the current view.

Without code splitting, a growing app forces every user to download the entire bundle before the page becomes interactive. With code splitting, you defer loading parts of the app (routes, heavy components, rarely used features) until they’re needed.

Why Code Splitting Matters

Dynamic Import

Code splitting is powered by the dynamic import() syntax. Unlike a static import, a dynamic import returns a Promise and tells the bundler (Vite, Webpack, etc.) to create a separate chunk:

// Static import — bundled into the main chunk
import { formatDate } from './utils';

// Dynamic import — split into its own chunk, loaded on demand
button.addEventListener('click', async () => {
  const { formatDate } = await import('./utils');
  console.log(formatDate(new Date()));
});

React.lazy

React.lazy lets you render a dynamically imported component as a regular component. It takes a function that calls a dynamic import() and returns a Promise resolving to a module with a default export.

import { lazy } from 'react';

// Instead of: import Dashboard from './Dashboard';
const Dashboard = lazy(() => import('./Dashboard'));

The Dashboard component’s code is only downloaded the first time it’s rendered.

Suspense

Because a lazily loaded component isn’t available immediately, React needs something to show while the chunk is loading. Suspense provides a fallback UI that’s displayed until the component is ready.

import { lazy, Suspense } from 'react';

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

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

Route-Based Code Splitting

The most impactful place to apply code splitting is at the route level, so each page loads only when the user visits it.

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

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

function App() {
  return (
    <BrowserRouter>
      <Suspense fallback={<div>Loading page...</div>}>
        <Routes>
          <Route path="/" element={<Home />} />
          <Route path="/profile" element={<Profile />} />
          <Route path="/settings" element={<Settings />} />
        </Routes>
      </Suspense>
    </BrowserRouter>
  );
}

Component-Based Code Splitting

You can also split heavy components that aren’t needed right away — such as modals, charts, or rich text editors:

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

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

function Reports() {
  const [showChart, setShowChart] = useState(false);

  return (
    <div>
      <button onClick={() => setShowChart(true)}>Show Chart</button>
      {showChart && (
        <Suspense fallback={<div>Loading chart...</div>}>
          <ChartModal />
        </Suspense>
      )}
    </div>
  );
}

Handling Load Errors

Network requests can fail, so a lazily loaded chunk might not load. Wrap Suspense in an Error Boundary to gracefully handle failures.

import { lazy, Suspense } from 'react';
import { ErrorBoundary } from 'react-error-boundary';

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

function App() {
  return (
    <ErrorBoundary fallback={<div>Something went wrong. Please retry.</div>}>
      <Suspense fallback={<div>Loading...</div>}>
        <Dashboard />
      </Suspense>
    </ErrorBoundary>
  );
}

Best Practices

Summary

Code splitting with React.lazy and Suspense lets you ship less JavaScript up front and load the rest on demand. By splitting at route and component boundaries, providing thoughtful loading fallbacks, and guarding against load errors, you can significantly improve your app’s initial load time and overall user experience.