React Performance18-07-202410 min read

Code Splitting and Lazy Loading in React

A practical guide to implementing code splitting and lazy loading in React applications to improve performance and reduce JavaScript bundle size.


Code Splitting and Lazy Loading in React


Modern React applications benefit significantly from loading only the code that users need at any given moment. Large bundles slow down the initial render, increase main thread work, and reduce responsiveness on low end devices. Code splitting and lazy loading allow React to defer loading non essential code until the moment it is required.


This guide explains how code splitting works, how to use lazy loaded components correctly, and how to design predictable loading boundaries in real applications.


1. Why Code Splitting Matters


Every React application ships JavaScript to the browser. The more JavaScript that must be parsed and executed before the first paint, the slower the app feels. Code splitting reduces the initial bundle by separating features into smaller chunks that load on demand.


The main benefits include:


  • faster initial load
  • less main thread blocking
  • fewer render delays on low end devices
  • improved Core Web Vitals such as LCP and INP
  • better control over when certain features become available

Code splitting is essential in applications with dashboards, charts, editors, maps, or multi route structures.


2. Lazy Loading Components with React.lazy


React provides a built in way to load components lazily using React.lazy. The component is only downloaded when it is first rendered.


Example:


import { Suspense, lazy } from "react";

const SettingsPanel = lazy(() => import("./SettingsPanel"));

export function AccountPage() {
  return (
    <Suspense fallback={<LoadingIndicator />}>
      <SettingsPanel />
    </Suspense>
  );
}

Important rules:


  • lazy loaded components must be wrapped in Suspense
  • the import must return a default export
  • the fallback should be lightweight to keep the UI responsive

Lazy loading works well for large components that are not required immediately during the initial render.


3. Route Level Code Splitting


Routing frameworks often support code splitting automatically. Even without framework help, you can lazy load route components manually.


const ReportsPage = lazy(() => import("./pages/ReportsPage"));

<Route
  path="/reports"
  element={
    <Suspense fallback={<PageLoading />}>
      <ReportsPage />
    </Suspense>
  }
/>

This prevents users from downloading the entire app before seeing the first screen.


Routes are one of the best places to apply code splitting because page boundaries represent natural loading points.


4. Splitting Heavy UI Sections


Large components such as charts, data grids, or editors can dramatically increase bundle size. These features should be loaded only when the user interacts with them.


Example:


const Chart = lazy(() => import("./Chart"));

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

  return (
    <section>
      <button onClick={() => setShowChart(true)}>Show Chart</button>

      {showChart && (
        <Suspense fallback={<InlineSpinner />}>
          <Chart />
        </Suspense>
      )}
    </section>
  );
}

This ensures that users who never open the chart avoid downloading it entirely.


5. Lazy Loading Utilities and Libraries


Dynamic imports can also load functions or libraries that are not needed during startup.


async function loadFormatter() {
  const { formatCurrency } = await import("./utils/formatCurrency");
  return formatCurrency;
}

This is useful for:


  • expensive formatting libraries
  • PDF generators
  • syntax highlighters
  • date or chart libraries

Keep core UI bundles light and load utilities on demand.


6. Designing Predictable Suspense Boundaries


Suspense fallbacks should match the scope of the loading area. Poorly scoped boundaries cause layout shifts or confusing user experiences.


Good patterns:


  • page level boundaries for route based code splitting
  • inline boundaries for small UI fragments
  • persistent containers that avoid shifting layout during load

Poor patterns:


  • wrapping the entire application in Suspense
  • loading large areas with minimal feedback
  • adding boundaries that cause flashing or repeated fallback states

A good Suspense boundary matches the user expectation of what is loading and why.


7. Prefetching and Preloading for Better UX


Browsers allow preloading or prefetching future chunks.


Preload when you know the user will need the code soon:


<link rel="preload" href="/static/Chart.chunk.js" as="script" />

Prefetch for likely future navigation:


<link rel="prefetch" href="/static/Settings.chunk.js" />

These hints improve perceived performance by letting the browser download future chunks while idle.


8. Avoid Over Splitting


Splitting too many modules creates overhead by generating many small network requests. Instead, aim for:


  • splitting by routes
  • splitting by large components
  • splitting rarely used features
  • splitting expensive libraries

Avoid splitting simple components or small utilities because the overhead outweighs the gains.


9. Handling Errors in Lazy Loaded Components


Dynamic imports can fail due to network issues or unexpected build artifacts. Wrap lazy loaded components with an Error Boundary to handle failures gracefully.


Example:


<ErrorBoundary fallback={<LoadError />}>
  <Suspense fallback={<Loading />}>
    <LazyChart />
  </Suspense>
</ErrorBoundary>

This keeps the app stable even when chunks fail to load.


Final Thoughts


Code splitting and lazy loading help React applications stay fast and responsive by reducing the initial JavaScript cost and loading features only when needed. Combined with well scoped Suspense boundaries and thoughtful preloading, these techniques provide a smooth user experience across devices and network conditions.