Code Splitting Strategies in React

Difficulty: Intermediate

Question

What is code splitting in React? How do you implement it with React.lazy?

Answer

Code splitting is one of those performance optimizations that has a huge impact for relatively little effort, and honestly, every production React app should be doing it. Let me explain what it is and why it matters.

When you build a React app with a bundler like Webpack or Vite, all your JavaScript gets combined into one big bundle file (or a few files). For a small app, that might be 100KB. For a real-world app with a rich editor, charts library, PDF viewer, and all the other things apps accumulate, that bundle can easily hit 2-5MB. The problem is that the browser has to download, parse, and execute ALL of that JavaScript before your app becomes interactive. A user visiting your login page has to wait for the admin dashboard code, the chart library, and every other page to download, even though they will never visit those pages in this session.

Code splitting solves this by breaking your bundle into smaller chunks that are loaded on demand. The user downloads only the code they need right now, and additional code is fetched when they navigate to new pages or interact with features that require it. This directly improves your Time to Interactive (TTI) -- the time until the user can actually click and type in your app.

The primary tool in React for code splitting is React.lazy combined with dynamic import(). React.lazy takes a function that calls import() -- the dynamic import syntax -- and returns a lazy-loaded component. When React first tries to render this component, it triggers the import, downloads the chunk, and renders the component once loaded. During the download, you need a loading state, which is where Suspense comes in. Suspense wraps lazy components and shows a fallback (like a spinner) while the chunk is loading.

The most impactful code splitting strategy is route-based splitting. Instead of importing all your page components at the top of your app file, you lazy-load them. Each route becomes its own chunk. When the user navigates to /dashboard, only the dashboard code is downloaded. When they go to /settings, the settings code downloads. The initial page load only includes the entry point and the first route.

In a typical React Router setup, you would do: const Dashboard = lazy(() => import('./pages/Dashboard')) instead of import Dashboard from './pages/Dashboard'. Then wrap your Routes in a Suspense boundary with a loading fallback. That is literally it for basic route splitting. Your bundler handles the rest -- it sees the dynamic import() and automatically creates a separate chunk.

Component-level splitting is the next level. Some components are heavy but only used in specific situations. A rich text editor, a chart library, a PDF viewer, a video player -- these might add 500KB+ each to your bundle. Lazy-load them so the code is only downloaded when the user actually needs that feature. If your app has a "Generate Report" button that opens a chart, there is no reason to download the entire charting library on page load.

A production best practice that many developers miss: always pair React.lazy with both Suspense AND an ErrorBoundary. Suspense handles the happy path (loading state while chunk downloads). But what if the network request fails? What if the user is on a flaky connection and the chunk download times out? Without an ErrorBoundary, your entire app crashes with an unhandled error. With an ErrorBoundary, you can show a friendly "Failed to load, click to retry" message.

Preloading is another technique that makes code splitting feel seamless. When a user hovers over a navigation link, you can start downloading the chunk for that page before they actually click. By the time they click, the chunk is already loaded (or mostly loaded), and the navigation feels instant. The implementation is simple: const preloadAdmin = () => import('./pages/Admin'). Then attach it to onMouseEnter on the link. Some bundlers and frameworks do this automatically.

There are a few gotchas to be aware of. React.lazy only works with default exports. If your component uses a named export, you need to create a wrapper module that re-exports it as default: lazy(() => import('./MyComponent').then(mod => ({ default: mod.MyComponent }))). Also, Suspense must be an ancestor (parent, grandparent, etc.) of the lazy component, not a sibling -- this is a common mistake.

If you are using Next.js, it provides its own next/dynamic function which is similar to React.lazy but with additional options like ssr: false for components that should only render on the client (like maps that use window or document). Vite also handles code splitting automatically for dynamic imports without any extra configuration.

One practical tip: use your browser DevTools Network tab or bundler analysis tools like webpack-bundle-analyzer or rollup-plugin-visualizer to see what is in your bundles and where splitting would have the biggest impact. Focus on the biggest chunks first -- splitting a 1MB charting library gives way more benefit than splitting a 5KB utility component.

Code examples

Route-Based Code Splitting

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

// Each route is loaded lazily
const Home = lazy(() => import('./pages/Home'));
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Profile = lazy(() => import('./pages/Profile'));
const AdminPanel = lazy(() => import('./pages/AdminPanel'));

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

function App() {
  return (
    <BrowserRouter>
      {/* Suspense catches lazy component loading */}
      <Suspense fallback={<LoadingSpinner />}>
        <Routes>
          <Route path="/" element={<Home />} />
          <Route path="/dashboard" element={<Dashboard />} />
          <Route path="/profile" element={<Profile />} />
          <Route path="/admin" element={<AdminPanel />} />
        </Routes>
      </Suspense>
    </BrowserRouter>
  );
}

// Named chunk for better debugging
const HeavyChart = lazy(() =>
  import(/* webpackChunkName: 'chart' */ './components/Chart')
);

Each route becomes a separate JS chunk. The Suspense fallback shows while the chunk loads. Initial bundle only includes the entry point code.

Component-Level Splitting and Error Handling

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

// Lazy-load heavy components
const RichEditor = lazy(() => import('./RichEditor'));
const VideoPlayer = lazy(() => import('./VideoPlayer'));
const PDFViewer = lazy(() => import('./PDFViewer'));

function ErrorFallback({ error, resetErrorBoundary }) {
  return (
    <div>
      <p>Failed to load component: {error.message}</p>
      <button onClick={resetErrorBoundary}>Retry</button>
    </div>
  );
}

function DocumentEditor({ docType, content }) {
  return (
    <ErrorBoundary FallbackComponent={ErrorFallback}>
      <Suspense fallback={<p>Loading editor...</p>}>
        {docType === 'rich' && <RichEditor content={content} />}
        {docType === 'video' && <VideoPlayer src={content} />}
        {docType === 'pdf' && <PDFViewer url={content} />}
      </Suspense>
    </ErrorBoundary>
  );
}

// Preloading a chunk before user navigates
const AdminPanel = lazy(() => import('./AdminPanel'));
const preloadAdmin = () => import('./AdminPanel'); // Start download early

<Link
  to="/admin"
  onMouseEnter={preloadAdmin} // Preload on hover
>Admin</Link>

Always wrap lazy components in both Suspense (loading) and ErrorBoundary (network failure). Preloading on hover further improves perceived performance.

Key points

Concepts covered

Code Splitting, React.lazy, Suspense, Dynamic Import, Bundle