WritingCode Splitting with React.lazy and Suspense: A Practical Deep Dive — LinkIT
5 min readreact, code-splitting, lazy-loading, suspense, bundle-size, performance

Code Splitting with React.lazy and Suspense: A Practical Deep Dive

A deep dive into code splitting with React.lazy and Suspense — how bundle splitting works, where to split, and how to avoid the pitfalls that slow initial load.

A React application can defer code for routes and components that a visitor does not need yet. Dynamic imports create those split points; React.lazy and Suspense connect loading to the component tree.

How Code Splitting with React.lazy Works

JavaScript bundlers — Webpack, Vite, esbuild — have a concept of dynamic imports. Instead of bundling a module into the main chunk at build time, a dynamic import produces a separate chunk file that is downloaded on demand.

React.lazy wraps a dynamic import and integrates it with React's rendering lifecycle. When React encounters a lazy component for the first time, it triggers the dynamic import, suspends rendering until the module resolves, and then renders the component.

import { lazy, Suspense } from "react";
 
const ReportingDashboard = lazy(() => import("./ReportingDashboard"));
 
function App() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <ReportingDashboard />
    </Suspense>
  );
}

The Suspense boundary is required. Without it, React will throw an error when the lazy component suspends. The fallback prop renders while the module is loading — typically a skeleton screen or a minimal loading indicator.

Route-Based Splitting

Routes are a useful place to start looking for split points. Each route in your application maps to a distinct feature — often one that imports its own set of libraries, components, and utilities. Splitting at routes produces separate chunks per page, so a user who lands on the home page downloads only the home page's code.

With React Router:

const SettingsPage = lazy(() => import("./pages/Settings"));
const AdminPanel = lazy(() => import("./pages/Admin"));
 
function Routes() {
  return (
    <Suspense fallback={<PageSkeleton />}>
      <RouterSwitch>
        <Route path="/settings" element={<SettingsPage />} />
        <Route path="/admin" element={<AdminPanel />} />
      </RouterSwitch>
    </Suspense>
  );
}

A single Suspense boundary around the route switch works for most applications. The fallback renders while any route's chunk is fetching.

Component-Level Splitting: When Routes Are Not Enough

Some components are large enough to justify splitting even within a route. Good candidates:

  • Modals and drawers that are conditionally rendered and import heavy libraries.
  • Rich text editors or code editors that bring in substantial dependencies.
  • Charts and visualization libraries that are only visible on interaction.
  • Admin-only panels that the majority of users never see.

Use bundle size, frequency of use, and loading latency to decide whether a component should load separately.

const RichTextEditor = lazy(() => import("./RichTextEditor"));
 
function PostForm({ showEditor }) {
  return (
    <div>
      {showEditor && (
        <Suspense fallback={<EditorSkeleton />}>
          <RichTextEditor />
        </Suspense>
      )}
    </div>
  );
}

Analyzing Your Bundle Before Splitting

Code splitting without a bundle analysis is guesswork. Run your bundler's analysis output first to see which modules are largest and which routes they belong to.

For Vite: install rollup-plugin-visualizer and run the build with the plugin enabled. For Webpack: use webpack-bundle-analyzer. Both produce a treemap showing every module's contribution to bundle size.

Look for:

  • Large third-party libraries imported on routes that don't need them.
  • Utilities or components that appear in every chunk because they are imported at the top of too many files.
  • Icon libraries where the full set is imported rather than individual icons.

The analysis tells you where splits will have real impact. Prioritize chunks whose removal would meaningfully reduce the initial download.

Avoiding the Pitfalls of React.lazy and Suspense

Waterfall loading

If a parent lazy component imports a child lazy component, they load sequentially — the parent must resolve before React discovers the child and starts its download. Flatten your lazy import hierarchy so sibling chunks load in parallel.

Missing error boundaries

Network requests fail. A user on a slow connection may fail to fetch a lazy chunk entirely. Without an error boundary above the Suspense component, a failed lazy import throws an unhandled error and breaks the entire subtree.

Add an error boundary — React's ErrorBoundary pattern or a library like react-error-boundary — above any Suspense that wraps lazy components in production.

Over-splitting small components

Splitting a 2KB component creates a network round trip that costs more than the bytes saved. The chunk must be discovered, requested, and parsed. Below a certain size threshold, the latency of the extra HTTP request outweighs the benefit of not bundling the code.

Named exports and lazy

React.lazy only works with default exports. If the component you want to lazy load uses a named export, wrap the import:

const MyComponent = lazy(() =>
  import("./MyComponent").then(mod => ({ default: mod.MyComponent }))
);

Prefetching for Perceived Performance

You can prefetch a lazy chunk before the user navigates to it. Hover can provide an opportunity to start downloading a chunk before a click.

Trigger the import on hover without rendering the component:

const prefetchReporting = () => import("./pages/Reporting");
 
return (
  <NavLink to="/reporting" onMouseEnter={prefetchReporting}>
    Reports
  </NavLink>
);

This can shorten the wait after a click, depending on network conditions and chunk size.

Measuring the Impact

After implementing route-based splitting, measure the initial bundle size using your bundler's stats output. Compare the total bytes downloaded on first load before and after. Then use a tool like Lighthouse or WebPageTest to measure Time to Interactive on a throttled connection.

Compare initial load and subsequent navigation on the devices and connections your users have. Extra chunk requests can offset savings from a smaller initial bundle.

Discuss React load performance with LinkIT.