Frontend

React Suspense and Lazy Loading

Mayur Dabhi
Mayur Dabhi
April 28, 2026
13 min read

You open the Network tab on a React app that's been in development for a year and the main bundle is a few megabytes. The login page needs maybe a tenth of that, but every visitor downloads the chart library, the rich text editor and the whole admin area before anything shows up. On a mid-range phone over a patchy connection, that's several seconds of white screen.

React ships two APIs for this, React.lazy() and Suspense, and you don't need any extra library to use them. This post covers how they work, how to handle named exports, error boundaries for failed chunks, route-based splitting, prefetching, and the cases where lazy loading makes things worse.

What you can expect

Route-based splitting usually cuts the initial JavaScript payload a lot, often by half or more in apps with several heavy pages. That shows up in Time to Interactive (TTI) and Largest Contentful Paint (LCP). How much you gain depends on how lopsided your bundle is, so measure your own app before and after.

Why one big bundle hurts

Without code splitting, your bundler (Webpack, Vite, or Parcel) concatenates every imported module into a single output file. Someone visiting your homepage downloads the code for every page: admin dashboards, settings panels, reporting screens. They may never open any of them.

Code splitting breaks that file into smaller chunks that load on demand. The mechanism is JavaScript's dynamic import(), which returns a Promise that resolves to the module. Bundlers see import() calls and emit a separate chunk for each one.

Without Splitting bundle.js (2.4 MB) All pages loaded upfront User loads homepage 2.4 MB downloaded With Splitting main.js (280 KB) dashboard.js 450 KB (lazy) settings.js (190 KB) reports.js (820 KB) User loads homepage 280 KB downloaded 🔴 Slow initial load 🟢 Fast initial load

One bundle vs. split chunks (sizes are illustrative)

React.lazy(): dynamic imports for components

You pass React.lazy() a function that calls import() and returns a Promise. React doesn't call it until the component is rendered for the first time, so the chunk isn't requested until then. It's been in React since 16.6.

Basic Syntax

App.jsx: lazy imports
import React, { lazy, Suspense } from 'react';

// Static import: bundled into main chunk, always loaded
import Header from './components/Header';
import Footer from './components/Footer';

// Lazy imports: each becomes its own chunk, loaded on demand
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings  = lazy(() => import('./pages/Settings'));
const Reports   = lazy(() => import('./pages/Reports'));

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

One catch: React.lazy() expects the module to have a default export. If it only has named exports, you get an error along the lines of "Element type is invalid. Received a promise that resolves to: undefined." Here are the ways around it.

Handling Named Exports

Named exports: workarounds
// --- Option 1: Re-export as default in a thin wrapper file ---
// file: src/lazy/Analytics.js
export { Analytics as default } from '../components/Analytics';

// Then lazy import normally:
const Analytics = lazy(() => import('./lazy/Analytics'));


// --- Option 2: Inline .then() transform ---
const Analytics = lazy(() =>
  import('../components/Analytics').then(module => ({
    default: module.Analytics
  }))
);


// --- Option 3: With Vite (supports both) ---
// Vite handles this natively with:
const Analytics = lazy(() =>
  import.meta.glob('../components/Analytics.jsx', { eager: false })['../components/Analytics.jsx']
);

I'd stick with Option 2 in most codebases. It works with any bundler and keeps the change in one place. Option 1 is handy when several files lazy import the same component.

Don't Lazy Load Everything

Small components (roughly under 10-15 KB gzipped) usually aren't worth splitting. The extra request and the fallback flash cost more than you save. Save it for large components, heavy third-party libraries (charts, rich text editors, PDF viewers) and anything not visible on first load.

Suspense: what shows while the chunk loads

While a lazy component's chunk is downloading, something has to render in its place. That's what Suspense is for. It shows its fallback until every lazy child inside it has resolved, then swaps in the real content. You don't manage any loading state yourself.

The fallback Prop

The fallback prop takes any React element: a spinner, a skeleton screen, or plain text. If you can, make it roughly the same shape as the content it replaces so the page doesn't jump when it loads.

Suspense with skeleton fallback
import { lazy, Suspense } from 'react';

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

// Simple spinner fallback
function SpinnerFallback() {
  return (
    <div style={{ display: 'flex', justifyContent: 'center', padding: '40px' }}>
      <div className="spinner" />
    </div>
  );
}

// Skeleton screen fallback: better UX, matches content shape
function ProfileSkeleton() {
  return (
    <div className="profile-skeleton">
      <div className="skeleton-avatar" />
      <div className="skeleton-name" />
      <div className="skeleton-bio" />
    </div>
  );
}

function UserPage() {
  return (
    <Suspense fallback={<ProfileSkeleton />}>
      <UserProfile userId={123} />
    </Suspense>
  );
}

Nested Suspense Boundaries

You can nest Suspense boundaries so different parts of the page get their own loading states. The main content can show up as soon as it's ready instead of waiting for the sidebar or an analytics panel nobody looks at first.

Nested Suspense boundaries
import { lazy, Suspense } from 'react';

// These load independently
const Sidebar   = lazy(() => import('./Sidebar'));
const MainFeed  = lazy(() => import('./MainFeed'));
const Comments  = lazy(() => import('./Comments'));
const Analytics = lazy(() => import('./Analytics'));

function Dashboard() {
  return (
    <div className="layout">
      {/* Sidebar loads with a lightweight skeleton */}
      <Suspense fallback={<SidebarSkeleton />}>
        <Sidebar />
      </Suspense>

      <main>
        {/* Main feed loads independently */}
        <Suspense fallback={<FeedSkeleton />}>
          <MainFeed />

          {/* Comments load after feed, nested inside */}
          <Suspense fallback={<div>Loading comments...</div>}>
            <Comments />
          </Suspense>
        </Suspense>
      </main>

      {/* Analytics panel loads last (less critical) */}
      <Suspense fallback={null}>
        <Analytics />
      </Suspense>
    </div>
  );
}
Dashboard Suspense fallback: SidebarSkeleton Suspense fallback: FeedSkeleton Suspense fallback: null lazy(Sidebar) lazy(MainFeed) Suspense (nested) fallback: Loading comments lazy(Comments) lazy(Analytics)

Nested Suspense boundaries: each section loads on its own

Error Boundaries with Suspense

Suspense handles loading, not errors. If a chunk request times out or returns a 404, the rejected Promise turns into a thrown error during render. You need an Error Boundary around the Suspense to catch it and show something useful.

Always Wrap Suspense in an Error Boundary

If a lazy chunk fails to download and nothing catches the error, React unmounts the whole tree and the user gets a blank page. With route-based splitting this happens on navigation, so someone clicks a link and the app just disappears.

The most common cause in production isn't a bad network, it's a deploy. A user has the old version open, you ship a new build with new chunk hashes, the old files get deleted, and their next navigation asks for a file that no longer exists. Webpack reports it as ChunkLoadError: Loading chunk 4 failed; with Vite it's TypeError: Failed to fetch dynamically imported module. Two fixes help: keep the previous build's assets on the server or CDN for a while after deploying, and have your error fallback offer a full page reload.

That last point matters because of a gotcha in the "Try again" button below. React.lazy() caches the result of your import function, including a rejection. Resetting the error boundary re-renders the same lazy component, which throws the same cached error without making a new request. A window.location.reload() is the simple fix; the other option is creating a fresh lazy() component on retry.

ErrorBoundary.jsx (class component required)
import { Component } from 'react';

class ErrorBoundary extends Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false, error: null };
  }

  static getDerivedStateFromError(error) {
    return { hasError: true, error };
  }

  componentDidCatch(error, info) {
    // Log to your error tracking service
    console.error('Lazy load failed:', error, info);
  }

  render() {
    if (this.state.hasError) {
      return this.props.fallback ?? (
        <div className="error-state">
          <h3>Failed to load this section</h3>
          <button onClick={() => this.setState({ hasError: false })}>
            Try again
          </button>
        </div>
      );
    }
    return this.props.children;
  }
}

export default ErrorBoundary;
Usage: ErrorBoundary + Suspense together
import { lazy, Suspense } from 'react';
import ErrorBoundary from './ErrorBoundary';
import PageLoader from './PageLoader';

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

function App() {
  return (
    <ErrorBoundary fallback={<ErrorPage message="Could not load dashboard" />}>
      <Suspense fallback={<PageLoader />}>
        <Dashboard />
      </Suspense>
    </ErrorBoundary>
  );
}

// Tip: Use the react-error-boundary package for a simpler hook-based API:
// import { ErrorBoundary } from 'react-error-boundary';
// <ErrorBoundary FallbackComponent={ErrorFallback} onError={logError}>
//   <Suspense fallback={<Spinner />}>
//     <Dashboard />
//   </Suspense>
// </ErrorBoundary>

Route-Based Code Splitting

If you only do one thing from this post, do this. Every page becomes its own chunk and downloads when the user goes to that route. With React Router v6 it's a small change to your route file.

App.jsx: route-based splitting with React Router v6
import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import ErrorBoundary from './ErrorBoundary';
import PageLoader from './components/PageLoader';

// All page components are lazy loaded
const Home      = lazy(() => import('./pages/Home'));
const About     = lazy(() => import('./pages/About'));
const Blog      = lazy(() => import('./pages/Blog'));
const BlogPost  = lazy(() => import('./pages/BlogPost'));
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings  = lazy(() => import('./pages/Settings'));
const NotFound  = lazy(() => import('./pages/NotFound'));

export default function App() {
  return (
    <BrowserRouter>
      <ErrorBoundary fallback={<PageError />}>
        <Suspense fallback={<PageLoader />}>
          <Routes>
            <Route path="/"          element={<Home />} />
            <Route path="/about"     element={<About />} />
            <Route path="/blog"      element={<Blog />} />
            <Route path="/blog/:id"  element={<BlogPost />} />
            <Route path="/dashboard" element={<Dashboard />} />
            <Route path="/settings"  element={<Settings />} />
            <Route path="*"          element={<NotFound />} />
          </Routes>
        </Suspense>
      </ErrorBoundary>
    </BrowserRouter>
  );
}
Metric Without Code Splitting With Route Splitting What changes
Initial JS Bundle Every page's code Shared code + current route Often a fraction of the original
Time to Interactive (slow network) Waits for the full bundle Waits for one route's chunk Noticeably faster on mobile
First Contentful Paint Blocked by parsing everything Less JS to parse first Usually better
Lighthouse Performance Flags unused JavaScript Less unused JS reported Score typically goes up
Navigation to new route Instant (already loaded) One network round trip the first time Use prefetching

Advanced Patterns

Prefetching for Instant Navigation

The cost of lazy loading is that the first visit to a route waits on a network request. You can hide most of that by starting the import before the user clicks. A hover or focus on the link gives you a couple hundred milliseconds of head start, and the module system caches the result, so the later lazy() call resolves right away.

Prefetching on hover
import { lazy } from 'react';
import { Link } from 'react-router-dom';

// Store a reference to the lazy() promise so we can call it early
const dashboardImport = () => import('./pages/Dashboard');
const Dashboard = lazy(dashboardImport);

// Prefetch on hover so it's cached by the time the user clicks
function NavLink({ to, prefetch, children, ...props }) {
  return (
    <Link
      to={to}
      onMouseEnter={prefetch}
      onFocus={prefetch}   // keyboard navigation
      {...props}
    >
      {children}
    </Link>
  );
}

// Usage:
<NavLink to="/dashboard" prefetch={dashboardImport}>
  Dashboard
</NavLink>

Webpack Magic Comments

Webpack reads special comments inside import() calls to name chunks and to add prefetch or preload hints. Named chunks alone are worth it: bundle reports become readable instead of a list of numbered files.

Webpack magic comments
// Named chunk: shows "dashboard" in bundle reports instead of "0.chunk.js"
const Dashboard = lazy(() =>
  import(/* webpackChunkName: "dashboard" */ './pages/Dashboard')
);

// Prefetch: browser downloads this chunk when idle, after current page loads
// Ideal for routes likely to be visited next
const Settings = lazy(() =>
  import(
    /* webpackChunkName: "settings" */
    /* webpackPrefetch: true */
    './pages/Settings'
  )
);

// Preload: fetched in parallel with the current chunk
// Use for resources critical to the current page
const HeroChart = lazy(() =>
  import(
    /* webpackChunkName: "hero-chart" */
    /* webpackPreload: true */
    './components/HeroChart'
  )
);

// Vite equivalent: use Rollup options in vite.config.js:
// build: { rollupOptions: { output: { manualChunks: { dashboard: ['./src/pages/Dashboard'] } } } }

webpackPrefetch vs webpackPreload: what's the difference?

webpackPrefetch: Injects a <link rel="prefetch"> tag. The browser fetches the chunk after the current page finishes loading, using idle network time. Best for "next likely page" resources that aren't immediately needed.

webpackPreload: Injects a <link rel="preload"> tag. The browser fetches the chunk in parallel with the current chunk. Best for resources that are definitely needed by the current page but discovered late (e.g., a font or image inside a dynamic component). Overuse can hurt performance by competing with critical resources.

Component-Level Splitting for Heavy Features

Splitting doesn't have to follow routes. Chart libraries, code editors, maps and PDF viewers are big, and they often sit behind a button or a tab, which makes them good candidates.

Lazy loading a heavy chart component
import { lazy, Suspense, useState } from 'react';

// recharts is heavy, so lazy load it with the chart component
const RevenueChart = lazy(() => import('./charts/RevenueChart'));

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

  return (
    <div>
      <h2>Revenue Overview</h2>

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

      {showChart && (
        <Suspense fallback={<ChartSkeleton height={300} />}>
          <RevenueChart data={revenueData} />
        </Suspense>
      )}
    </div>
  );
}

// You can also use a custom hook to manage state + loading together
function useConditionalLazy(condition, importFn) {
  const [Component, setComponent] = useState(null);

  useEffect(() => {
    if (condition) {
      importFn().then(m => setComponent(() => m.default));
    }
  }, [condition]);

  return Component;
}

Step-by-Step Implementation Guide

1

Analyse Your Current Bundle

Use webpack-bundle-analyzer, or rollup-plugin-visualizer on Vite, to see which modules take up the most space before you start splitting.

2

Identify Lazy-Load Candidates

Look for: page/route components, components behind interactions (modals, dropdowns, tabs), heavy third-party libraries not used on initial render, and admin or authenticated-only sections.

3

Convert Static Imports to React.lazy()

Replace import Component from './Component' with const Component = lazy(() => import('./Component')) for each candidate. Check that the target file has a default export.

4

Wrap with Suspense and Error Boundary

Place a <Suspense fallback={...}> around each lazy component. Put an ErrorBoundary around it too, so a failed chunk shows an error message instead of a blank page.

5

Test and Measure

Use Chrome DevTools → Network tab with CPU/Network throttling to simulate slow connections. Check that fallbacks look right and navigation works on both fast and slow connections. Also try blocking a chunk URL in DevTools (right click the request, "Block request URL") to see your error boundary in action.

When I'd lazy load (and when I wouldn't)

I'd split by route in almost any React app with more than a handful of pages, and I'd lazy load any big library that sits behind a click: charts, editors, maps, PDF viewers. That's where most of the gain is, and the code changes are small.

Where it backfires

  • Small apps: If your whole bundle is a couple hundred KB, splitting adds requests and loading flashes for very little benefit.
  • Above-the-fold content: Lazy loading the hero section or main nav just delays what the user came to see. Keep it in the main chunk.
  • Tiny components: A modal with a form in it isn't worth its own chunk. The fallback flash is more noticeable than the bytes you saved.
  • Waterfalls: A lazy page that renders a lazy section that renders another lazy widget loads them one after another. Flatten it, or prefetch the inner chunks when the page starts loading.
  • Fallback flicker on fast connections: A skeleton that shows for 50ms looks like a glitch. Wrapping navigations in startTransition lets React keep the old screen up while the new chunk loads, instead of showing the fallback.

Before shipping, open the bundle analyzer once more and check that the heavy libraries actually ended up in the lazy chunks. A single static import of the same module anywhere else pulls it back into the main bundle, and nothing warns you about it.

React Suspense Performance Code Splitting Lazy Loading Dynamic Imports
Mayur Dabhi

Mayur Dabhi

Full Stack Developer with 5+ years of experience building scalable web applications with Laravel, React, and Node.js.