Skip to content

React · Rendering and Performance

React Code Splitting and Lazy Loading — A Complete Guide

7 min read Updated 2026-06-24 Share:

Practice Code Splitting and Lazy Loading interview questions

The problem: one giant bundle

When you build a React app without code splitting, every component, library, and utility ends up in a single JavaScript file. A user who lands on your home page downloads the code for the settings panel, the admin dashboard, the PDF viewer — everything — before they see anything interactive.

The result is a high Time to Interactive (TTI), slow First Contentful Paint (FCP), and unnecessary parse/compile work on the main thread. Code splitting fixes this by breaking the bundle into smaller chunks that are fetched on demand, so the initial page only loads what it actually needs.

React.lazy and dynamic import()

React's built-in answer to component-level splitting is React.lazy paired with the native import() function. The dynamic import() is a bundler hint — Webpack and Vite see it and automatically emit a separate chunk at build time.

import { lazy, Suspense } from 'react'

// Static import — always in the initial bundle
// import Dashboard from './Dashboard'

// Lazy import — Dashboard becomes its own chunk, loaded on demand
const Dashboard = lazy(() => import('./Dashboard'))

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

When React first tries to render <Dashboard />, it fires the import() call. While the chunk is in flight, the nearest <Suspense> boundary renders the fallback. Once the chunk resolves, React swaps in the real component. On subsequent renders the module is cached — the fallback never shows again.

Suspense boundaries: placement and granularity

React.lazy requires a <Suspense> ancestor somewhere above it in the tree. Where you place the boundary controls how much of the UI is replaced by the fallback during loading.

A single top-level boundary shows a full-page spinner — simple, but the whole page blanks out while any lazy chunk is pending. Nested boundaries let each section load independently, showing skeletons that match the real layout and keeping the rest of the page fully interactive.

// Top-level — entire page replaced by fallback
<Suspense fallback={<PageSpinner />}>
  <Sidebar />
  <MainContent />
</Suspense>

// Nested — each section has its own fallback
<>
  <Suspense fallback={<SidebarSkeleton />}>
    <Sidebar />
  </Suspense>

  <Suspense fallback={<ContentSkeleton />}>
    <MainContent />
  </Suspense>
</>

Use a skeleton that matches the real component's height and layout to avoid Cumulative Layout Shift (CLS) — the page should not jump when the real content appears.

Route-based splitting with React Router

Route-based splitting is the highest-leverage change you can make. Each route is a natural isolation boundary; users rarely visit every route in a session, so there is no reason to ship every route's code on the first load.

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

const Home      = lazy(() => import(/* webpackChunkName: "home"      */ './pages/Home'))
const Dashboard = lazy(() => import(/* webpackChunkName: "dashboard" */ './pages/Dashboard'))
const Settings  = lazy(() => import(/* webpackChunkName: "settings"  */ './pages/Settings'))

function App() {
  return (
    <BrowserRouter>
      {/* One Suspense wraps all routes — each route chunk is fetched on navigate */}
      <Suspense fallback={<PageSpinner />}>
        <Routes>
          <Route path="/"          element={<Home />} />
          <Route path="/dashboard" element={<Dashboard />} />
          <Route path="/settings"  element={<Settings />} />
        </Routes>
      </Suspense>
    </BrowserRouter>
  )
}

The /* webpackChunkName */ magic comment gives each chunk a human-readable filename in the build output, which makes bundle analysis and production error logs far easier to read.

Error boundaries: handling chunk load failures

A <Suspense> boundary only catches the pending state. If a chunk fetch fails — due to a network error, a 404 after a deploy, or the user going offline — React propagates an error up the tree. You need an Error Boundary to intercept it and give the user a recovery path.

import { Component } from 'react'

class ChunkErrorBoundary extends Component {
  state = { hasError: false }

  static getDerivedStateFromError() {
    return { hasError: true }
  }

  render() {
    if (this.state.hasError) {
      return (
        <div>
          <p>Failed to load. Check your connection and try again.</p>
          <button onClick={() => this.setState({ hasError: false })}>
            Retry
          </button>
        </div>
      )
    }
    return this.props.children
  }
}

// Error boundary wraps Suspense, not the other way around
<ChunkErrorBoundary>
  <Suspense fallback={<Spinner />}>
    <LazyDashboard />
  </Suspense>
</ChunkErrorBoundary>

The library react-error-boundary provides a hook-friendly version of this pattern if you want to avoid writing class components.

Prefetching: loading before the user asks

Once your initial bundle is lean, you can use idle time to prefetch chunks the user is likely to need next. Because the browser caches import() results by URL, calling the same import path before React.lazy needs it means the chunk is already available when navigation happens — the Suspense fallback is skipped entirely.

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

function NavLink() {
  // Trigger the fetch on hover — ~100–300 ms head start before the click
  const prefetch = () => import('./pages/Dashboard')

  return (
    <a href="/dashboard" onMouseEnter={prefetch} onFocus={prefetch}>
      Dashboard
    </a>
  )
}

// Or prefetch everything after the page is idle
if (typeof requestIdleCallback !== 'undefined') {
  requestIdleCallback(() => {
    import('./pages/Dashboard')
    import('./pages/Settings')
  })
}

Webpack's /* webpackPrefetch: true */ magic comment automates this by emitting a <link rel="prefetch"> tag in the HTML, so the browser handles it natively during idle time.

Measuring the impact with bundle analysis

You cannot optimize what you cannot see. Run bundle analysis before and after splitting to confirm actual savings.

# Vite — rollup-plugin-visualizer
# Add to vite.config.ts:
# import { visualizer } from 'rollup-plugin-visualizer'
# plugins: [react(), visualizer({ open: true, gzipSize: true })]
npm run build

# Webpack — webpack-bundle-analyzer
npx webpack --profile --json > stats.json
npx webpack-bundle-analyzer stats.json

# Check chunk sizes in the build output
# dist/assets/index-3f2a.js      → 87 KB  (initial)
# dist/assets/dashboard-9c1b.js  → 142 KB (on demand ✓)
# dist/assets/reports-4d7e.js    → 198 KB (on demand ✓)

Also check the Network tab in DevTools with CPU and network throttling enabled — confirm that on-demand chunks appear as separate requests timed to navigation, not on the initial page load. Set a bundle size budget in CI (via bundlesize or Vite's build.chunkSizeWarningLimit) to catch regressions before they reach production.

Key takeaways

  • Use React.lazy + import() to split any component into its own chunk.
  • Always wrap lazy components in <Suspense> and pair with an Error Boundary.
  • Start with route-based splitting — it gives the biggest wins for the least complexity.
  • Name your chunks with magic comments to make build output readable.
  • Prefetch on hover or in idle time to make lazy loading invisible to the user.
  • Measure with a bundle analyzer before and after — and again in CI to prevent regressions.

More ways to practice

The self-quiz is live. Get notified when mock interviews and new question packs drop.

or
Join our WhatsApp Channel