Intermediate0 questionsFull Guide

React Code Splitting & Lazy Loading — Complete Interview Guide

Master React code splitting — how React.lazy and Suspense work together, why route-based splitting gives the biggest wins, how to handle failed lazy loads, and what named exports require.

The Mental Model

Code splitting breaks your JavaScript bundle into smaller chunks that load on demand. Instead of forcing every user to download your entire application upfront, you deliver the minimum code needed for the current page and fetch additional chunks only when the user navigates or triggers a feature. React.lazy wraps the dynamic import — it's the lazy loader. Suspense provides the loading boundary — it shows a fallback while the chunk downloads. Together they give you on-demand loading with almost no boilerplate.

The Explanation

The Bundle Problem

Without code splitting, your bundler (webpack, Vite) combines every JavaScript file in your app into one large file. Every user downloads this bundle on their first visit — even if they only use 10% of the features. A user landing on the login page downloads all the code for the admin panel, the user profile, the analytics dashboard, and every other page they haven't visited yet.

Code splitting solves this by creating separate JavaScript chunks for different parts of the app, loaded on demand.

Dynamic import() — The Foundation

Before React.lazy, bundlers introduced import() as a dynamic version of the static import statement. It returns a Promise that resolves to the module:

// Static import — loaded immediately, part of the main bundle
import Dashboard from './Dashboard';

// Dynamic import — loaded on demand, creates a separate chunk
import('./Dashboard').then(module => {
  const Dashboard = module.default;
  render(<Dashboard />);
});

Bundlers automatically create a separate JavaScript file (chunk) for every dynamic import target. The chunk is only fetched when the import() call executes.

React.lazy — Declarative Lazy Loading

React.lazy wraps a dynamic import so you can use the lazily-loaded component just like any other React component. It must receive a function that returns a Promise resolving to a module with a default export:

// Without lazy — AdminPanel is in the main bundle
import AdminPanel from './AdminPanel';

// With lazy — AdminPanel becomes a separate chunk, loaded on demand
const AdminPanel = React.lazy(() => import('./AdminPanel'));

// Usage is identical — React handles the loading
function App() {
  return (
    <Suspense fallback={<Spinner />}>
      <AdminPanel />
    </Suspense>
  );
}
React.lazy only works with default exports. If the component is a named export, create an intermediate module that re-exports it as default, or use the workaround below.

Suspense — The Loading Boundary

Suspense is the boundary component that shows a fallback UI while a lazy component's chunk is downloading. It can wrap one or many lazy components — it shows the fallback if any child is still loading:

// One Suspense, multiple lazy children — fallback shows until ALL are ready
<Suspense fallback={<PageSkeleton />}>
  <LazyHeader />
  <LazyContent />
  <LazyFooter />
</Suspense>

// Nested Suspense — each section has its own loading state
<Suspense fallback={<HeaderSkeleton />}>
  <LazyHeader />
  <Suspense fallback={<ContentSkeleton />}>
    <LazyContent />
  </Suspense>
</Suspense>

Route-Based Splitting — Biggest Impact

The highest-ROI code splitting strategy is to split at the route level — each page becomes its own chunk. A user visiting /dashboard never downloads the code for /settings:

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

function App() {
  return (
    <Suspense fallback={<PageLoader />}>
      <Routes>
        <Route path="/"          element={<Home />} />
        <Route path="/dashboard" element={<Dashboard />} />
        <Route path="/settings"  element={<Settings />} />
      </Routes>
    </Suspense>
  );
}

Error Boundaries + Lazy = Required Pairing

Lazy loading can fail — the network is unreliable, a deployment changed the chunk filename, or the user's browser is offline. Without an Error Boundary, a failed lazy load crashes the entire app. Always pair Suspense with an Error Boundary:

<ErrorBoundary fallback={<p>Failed to load. <button onClick={retry}>Retry</button></p>}>
  <Suspense fallback={<Spinner />}>
    <LazyComponent />
  </Suspense>
</ErrorBoundary>

Named Exports Workaround

React.lazy requires a default export. For components exported as named exports, create a re-export:

// ❌ Won't work — lazy requires default export
const { UserCard } = React.lazy(() => import('./components'));

// ✅ Option 1: Re-export as default in a wrapper file
// LazyUserCard.js
export { UserCard as default } from './components';

// Then use it
const UserCard = React.lazy(() => import('./LazyUserCard'));

// ✅ Option 2: Inline re-export in the lazy call
const UserCard = React.lazy(() =>
  import('./components').then(module => ({ default: module.UserCard }))
);

Preloading for Instant Feels

You can trigger a lazy component's chunk download before the user needs it — on hover, on route transition start, or after the initial page loads — so the chunk is already cached when it's needed:

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

// Preload on hover — by the time the user clicks, the chunk is cached
function NavLink() {
  function handleMouseEnter() {
    import('./Dashboard'); // triggers the download, React.lazy will use the cached result
  }
  return (
    <a href="/dashboard" onMouseEnter={handleMouseEnter}>Dashboard</a>
  );
}

Common Misconceptions

⚠️

Many developers think code splitting automatically improves performance — if all your split chunks are loaded immediately on page load (e.g. all route chunks eagerly fetched), splitting adds overhead (more HTTP requests) with no benefit. Code splitting only helps when chunks are genuinely deferred until needed.

⚠️

Many developers think React.lazy works with named exports — it requires a Promise that resolves to a module with a default export. For named exports, you must either re-export as default or use the .then(module => ({ default: module.Named })) pattern.

⚠️

Many developers forget Error Boundaries alongside Suspense for lazy loading — a failed chunk load (network error, deployment mismatch) will crash the app if there's no Error Boundary catching the load failure. Suspense + ErrorBoundary is always a pair.

⚠️

Many developers split every component into its own chunk — over-splitting creates hundreds of tiny HTTP requests that hurt performance more than the large bundle did. The right granularity is route-level for SPAs and large feature modules for component libraries.

⚠️

Many developers think Suspense is only for lazy loading — Suspense is a general loading boundary. React Query, Relay, and React 18's use() hook all integrate with Suspense for data fetching. Understanding Suspense as a 'loading boundary' (not a 'lazy boundary') is the complete mental model.

⚠️

Many developers think dynamic import() is a React feature — it's a JavaScript/bundler feature (webpack, Vite, Rollup). React.lazy is just a React wrapper around dynamic import(). The chunk creation happens at the bundler level, not in React.

Where You'll See This in Real Code

Multi-page SPAs: React Router apps split each page route into its own chunk — the /checkout page code isn't downloaded until the user navigates to it, cutting initial bundle size by 60-80% for large apps.

Heavy feature modules: a rich text editor (TipTap, Quill) or data visualisation library (D3, Recharts) is only loaded when the user opens the feature that needs it, not on every page load.

Admin panels: admin features are lazily loaded because most users never access them — splitting /admin routes means regular users never pay the download cost for admin-only code.

Internationalisation: instead of bundling all locale files upfront, lazy-load the specific locale module when the user selects their language.

Map integrations: a Mapbox or Google Maps component is expensive to load. Lazy-loading it means the map chunk is only downloaded when a map-containing page is visited.

Next.js App Router: Next.js performs automatic code splitting per page and layout by default. Understanding React.lazy and Suspense helps you reason about how Next.js's automatic splitting works and how to add component-level splits on top of it.

Interview Cheat Sheet

  • React.lazy(() => import('./Comp')) — creates a lazily loaded component from a dynamic import
  • React.lazy requires a default export — use .then(m => ({ default: m.Named })) for named exports
  • Suspense fallback={<Spinner />} — shows fallback while lazy component chunk is downloading
  • Always pair with ErrorBoundary — a failed chunk load crashes the app without it
  • Route-level splitting = biggest performance win for SPAs
  • Nested Suspense = granular loading states (each section has its own skeleton)
  • Preloading: call import('./Comp') on hover/focus before the user navigates — chunk is cached when needed
  • Over-splitting hurts: aim for route-level + large feature modules, not every component
💡

How to Answer in an Interview

  • 1.Lead with the problem: 'Without code splitting, every user downloads the entire app bundle on first load — including code for pages they may never visit. Code splitting creates separate chunks that load on demand, reducing initial load time.'
  • 2.Explain the React.lazy + Suspense pairing as two separate roles: 'React.lazy wraps the dynamic import — it's the loader. Suspense is the boundary that shows a fallback while the chunk downloads — it's the UI gate. They always work together.'
  • 3.The Error Boundary + Suspense point impresses: 'Lazy loading can fail — network issues, deployment mismatches. Without an Error Boundary, a failed load crashes the whole app. I always wrap lazy-loaded sections in both: ErrorBoundary → Suspense → LazyComponent. The ErrorBoundary catches load failures; Suspense handles the loading state.'
  • 4.The named export limitation is a common gotcha: 'React.lazy only accepts a Promise resolving to a module with a default export. For named exports, I use the .then trick: React.lazy(() => import("./file").then(m => ({ default: m.Named })))'
  • 5.Route-level splitting first, always: 'The highest ROI split is at the route level — each page becomes its own chunk. Users visiting /dashboard never download /settings code. I add component-level splits only when bundle analysis shows a specific heavy component worth isolating.'
  • 6.Connect to Next.js/modern frameworks: 'Next.js does route-based code splitting automatically. Understanding React.lazy and Suspense helps you reason about what Next.js is doing and how to add component-level splits on top — for example, lazily loading a heavy chart library only on the analytics page.'

Practice Questions

No questions tagged to this topic yet.

Related Topics

React Error Boundaries — Complete Interview Guide
Intermediate·6–10 Qs
Concurrent Rendering (React 18) Interview Questions
Advanced·4–8 Qs
React Rendering & Performance Interview Questions
Advanced·4–8 Qs
🎯

Can you answer these under pressure?

Reading answers is not the same as knowing them. Practice saying them out loud with AI feedback — that's what builds real interview confidence.

Practice Free →Try Output Quiz