React Interview Prep
Performance Optimization

Code Splitting & Lazy Loading

Ship Less JavaScript, Load Faster

LinkedIn Hook

Most React apps ship a single massive JavaScript bundle. Every component, every page, every library — downloaded and parsed before the user sees a single pixel. And developers wonder why their app feels slow.

Code splitting is how production React apps solve this. Instead of one giant bundle, you split your code into smaller chunks that load on demand. The user visiting the homepage never downloads the admin dashboard code. The settings page loads only when someone navigates there.

Yet in interviews, most candidates cannot explain the difference between route-based and component-based splitting. They have never used React.lazy() with Suspense. They do not know what dynamic import() actually returns or why it is the foundation of every splitting strategy.

Interviewers will ask: "Your app takes 8 seconds to load on mobile. The bundle is 2.4MB. How do you reduce the initial load time without removing features?" They want to hear you talk about code splitting at the route level, lazy-loading heavy components, and Suspense boundaries that show meaningful fallbacks instead of blank screens.

In this lesson, I break down every piece: dynamic import(), React.lazy(), Suspense with fallback UI, route-based splitting, component-based splitting, and the strategies that turn a slow monolith into a fast, progressively-loaded application.

If your React app ships everything in one bundle — this lesson will change how you architect your builds.

Read the full lesson -> [link]

#React #JavaScript #InterviewPrep #Frontend #Performance #CodeSplitting #WebDevelopment #CodingInterview #100DaysOfCode


Code Splitting & Lazy Loading thumbnail


What You'll Learn

  • How dynamic import() works and why it is the foundation of code splitting
  • How to lazy-load components with React.lazy() and wrap them in Suspense
  • How to implement route-based splitting so each page loads independently
  • How to apply component-based splitting for heavy UI elements like charts, editors, and modals
  • How code splitting reduces initial bundle size and improves Time to Interactive

The Concept — Load Only What You Need

Analogy: The Restaurant Kitchen

Imagine a restaurant that prepares every dish on the menu the moment it opens — all 200 dishes, cooked and plated, sitting on the counter before a single customer walks in. Breakfast items, lunch specials, dinner entrees, desserts — everything ready at 6 AM. Most of it goes cold. Most of it is wasted. The kitchen is overwhelmed, and the first customer waits 45 minutes just to get a coffee because the staff was busy preparing dishes nobody ordered yet.

That is what a React app does when it ships one giant bundle. Every page, every feature, every component — downloaded, parsed, and ready before the user even clicks anything.

Now imagine a smarter restaurant. It has a small menu of starters ready to go — coffee, toast, the popular items. When a customer orders the lobster thermidor, the kitchen starts preparing it at that moment. The risotto gets made when someone orders it. The dessert menu only gets printed when someone asks for it.

That is code splitting. Your app ships only the code needed for the current view. When the user navigates to a new page or opens a heavy feature, the code for that piece loads on demand.

React.lazy() is the kitchen receiving the order — it triggers the load. Suspense is the waiter telling the customer "your dish is being prepared" — it shows a fallback while the code downloads. Dynamic import() is the delivery truck bringing ingredients from the warehouse — it fetches the chunk from the server.

The result: the first customer gets their coffee in seconds, not minutes. And the lobster thermidor is just as good when it arrives — it was just prepared at the right time.


Dynamic import() — The Foundation

Before React.lazy() can work, you need to understand dynamic import(). Unlike static import at the top of a file (which bundles everything at build time), dynamic import() returns a Promise that resolves to the module. The bundler (Webpack, Vite) sees this and automatically creates a separate chunk.

Code Example 1: Dynamic import() vs Static import

// STATIC import — bundled into the main chunk at build time
// This code is ALWAYS included, even if the user never visits the admin page
import AdminDashboard from "./AdminDashboard";

// DYNAMIC import — creates a separate chunk, loaded on demand
// Returns a Promise that resolves to the module
const loadAdmin = () => import("./AdminDashboard");

// Using it manually (you rarely do this directly — React.lazy handles it)
async function showAdmin() {
  // The browser fetches the AdminDashboard chunk only when this runs
  const module = await import("./AdminDashboard");

  // module.default is the component (because it was a default export)
  console.log(module.default);
}

// What the bundler does behind the scenes:
// 1. Sees import("./AdminDashboard")
// 2. Creates a separate file: AdminDashboard.chunk.js
// 3. At runtime, fetches that file over the network when import() is called
// 4. Resolves the Promise with the module contents

// Output when showAdmin() is called:
// Network tab shows: AdminDashboard.chunk.js downloaded
// Console: [Function: AdminDashboard]

Key point: Dynamic import() is a JavaScript language feature, not a React feature. React.lazy() is simply a wrapper that makes dynamic imports work seamlessly with React's component model.


React.lazy() and Suspense — The React Way

React.lazy() takes a function that calls dynamic import() and returns a special lazy component. This component behaves like a normal component, but its code is only fetched when it first renders. Suspense provides the loading UI while the chunk downloads.

Code Example 2: Route-Based Code Splitting

import { BrowserRouter, Routes, Route, Link } from "react-router-dom";
import { lazy, Suspense } from "react";

// Each page is lazy-loaded — the bundler creates a separate chunk for each
// These imports run ONLY when the user navigates to that route
const Home = lazy(() => import("./pages/Home"));
const Dashboard = lazy(() => import("./pages/Dashboard"));
const Settings = lazy(() => import("./pages/Settings"));
const AdminPanel = lazy(() => import("./pages/AdminPanel"));

// A reusable loading fallback component
function PageLoader() {
  return (
    <div style={{ padding: "40px", textAlign: "center" }}>
      <p>Loading page...</p>
    </div>
  );
}

function App() {
  return (
    <BrowserRouter>
      {/* Navigation is always visible — it is in the main bundle */}
      <nav>
        <Link to="/">Home</Link>
        <Link to="/dashboard">Dashboard</Link>
        <Link to="/settings">Settings</Link>
        <Link to="/admin">Admin</Link>
      </nav>

      {/* Suspense wraps lazy components and shows fallback while loading */}
      {/* You can wrap all routes in one Suspense or each route individually */}
      <Suspense fallback={<PageLoader />}>
        <Routes>
          <Route path="/" element={<Home />} />
          <Route path="/dashboard" element={<Dashboard />} />
          <Route path="/settings" element={<Settings />} />
          <Route path="/admin" element={<AdminPanel />} />
        </Routes>
      </Suspense>
    </BrowserRouter>
  );
}

export default App;

// Initial page load (user visits /):
//   Main bundle: ~150KB (App, Nav, React, Router)
//   Home chunk: ~30KB (loaded immediately because route matches)
//   Dashboard, Settings, Admin: NOT downloaded yet

// User clicks "Dashboard":
//   Network tab: Dashboard.chunk.js downloaded (~45KB)
//   Screen shows "Loading page..." for ~200ms, then Dashboard renders

// User clicks "Admin":
//   Network tab: AdminPanel.chunk.js downloaded (~80KB)
//   Screen shows "Loading page..." briefly, then AdminPanel renders

// Result: Initial bundle went from ~305KB to ~180KB
// Each page loads only when the user actually needs it

Key point: Route-based splitting is the most impactful optimization because pages are natural boundaries. A user visiting the homepage should never pay the cost of downloading the admin panel code.


Component-Based Splitting — Heavy Features on Demand

Not all splitting happens at the route level. Some pages contain heavy components — chart libraries, rich text editors, PDF viewers, complex modals — that should load only when the user triggers them.

Code Example 3: Lazy-Loading a Heavy Component

import { lazy, Suspense, useState } from "react";

// ChartDashboard uses a heavy charting library (e.g., recharts, chart.js)
// It adds ~200KB to the bundle — we only load it when the user clicks "Show Charts"
const ChartDashboard = lazy(() => import("./ChartDashboard"));

// MarkdownEditor uses a rich text library — another ~150KB
// Only loaded when the user clicks "Write Post"
const MarkdownEditor = lazy(() => import("./MarkdownEditor"));

function AnalyticsPage() {
  const [showCharts, setShowCharts] = useState(false);
  const [showEditor, setShowEditor] = useState(false);

  return (
    <div>
      <h1>Analytics</h1>

      {/* Summary stats are always visible — they are lightweight */}
      <div>
        <p>Total Users: 12,450</p>
        <p>Active Today: 1,230</p>
        <p>Revenue: $45,600</p>
      </div>

      {/* Charts load only when the user wants to see them */}
      <button onClick={() => setShowCharts(true)}>Show Charts</button>

      {showCharts && (
        // Each heavy component gets its own Suspense boundary
        // This way, loading one does not affect the rest of the page
        <Suspense fallback={<p>Loading charts...</p>}>
          <ChartDashboard />
        </Suspense>
      )}

      {/* Editor loads only when the user wants to write */}
      <button onClick={() => setShowEditor(true)}>Write Post</button>

      {showEditor && (
        <Suspense fallback={<p>Loading editor...</p>}>
          <MarkdownEditor />
        </Suspense>
      )}
    </div>
  );
}

export default AnalyticsPage;

// Initial load of /analytics:
//   AnalyticsPage chunk: ~5KB (just stats and buttons)
//   Charts and Editor: NOT downloaded

// User clicks "Show Charts":
//   Network: ChartDashboard.chunk.js (~200KB) downloaded
//   "Loading charts..." appears briefly, then charts render

// User never clicks "Write Post":
//   MarkdownEditor.chunk.js is NEVER downloaded — zero cost

// Result: The page loads instantly with stats visible
// Heavy features load progressively based on user interaction

Code Example 4: Preloading Lazy Components on Hover

import { lazy, Suspense } from "react";
import { BrowserRouter, Routes, Route, Link } from "react-router-dom";

// Store the import function so we can call it for preloading
const importDashboard = () => import("./pages/Dashboard");
const importSettings = () => import("./pages/Settings");

// Create lazy components from the same import functions
const Dashboard = lazy(importDashboard);
const Settings = lazy(importSettings);

function Navigation() {
  return (
    <nav>
      {/* Preload the Dashboard chunk when the user hovers over the link */}
      {/* By the time they click, the chunk is already downloaded */}
      <Link to="/dashboard" onMouseEnter={() => importDashboard()}>
        Dashboard
      </Link>

      {/* Same for Settings — hover triggers the download */}
      <Link to="/settings" onMouseEnter={() => importSettings()}>
        Settings
      </Link>
    </nav>
  );
}

function App() {
  return (
    <BrowserRouter>
      <Navigation />
      <Suspense fallback={<p>Loading...</p>}>
        <Routes>
          <Route path="/dashboard" element={<Dashboard />} />
          <Route path="/settings" element={<Settings />} />
        </Routes>
      </Suspense>
    </BrowserRouter>
  );
}

export default App;

// User hovers over "Dashboard" link:
//   Network: Dashboard.chunk.js starts downloading immediately
//   The user has not clicked yet — we are using idle time to preload

// User clicks "Dashboard" 300ms later:
//   Chunk is already cached — component renders instantly, no loading spinner
//   Suspense fallback never shows because the chunk was preloaded

// Result: Combines the bundle size benefits of code splitting
// with the instant-feel of having everything preloaded

Key point: Preloading on hover is a production pattern used by frameworks like Next.js. It gives you the best of both worlds — small initial bundle, but near-instant navigation when the user signals intent.


Code Splitting & Lazy Loading visual 1


Code Splitting & Lazy Loading visual 2


Common Mistakes

Mistake 1: Wrapping every tiny component in React.lazy

// BAD: Lazy-loading a small button or icon adds network overhead
// The HTTP request cost outweighs the bundle savings
const Button = lazy(() => import("./Button"));         // 2KB component
const Icon = lazy(() => import("./Icon"));             // 1KB component
const Divider = lazy(() => import("./Divider"));       // 0.5KB component

// Each lazy component = 1 network request + 1 Suspense boundary
// For tiny components, the overhead of the request is MORE than the bytes saved

// GOOD: Only lazy-load components that are large or rarely used
const ChartDashboard = lazy(() => import("./ChartDashboard"));  // 200KB library
const PDFViewer = lazy(() => import("./PDFViewer"));            // 150KB library
const AdminPanel = lazy(() => import("./pages/AdminPanel"));    // Full page, 80KB

// Rule of thumb: lazy-load pages (route-based) and heavy features (50KB+)
// Keep small, frequently-used components in the main bundle

Mistake 2: Missing the Suspense boundary

// BAD: Lazy component rendered without Suspense — React throws an error
const Dashboard = lazy(() => import("./Dashboard"));

function App() {
  return (
    <div>
      {/* This crashes: "A component suspended while rendering,
          but no fallback UI was specified" */}
      <Dashboard />
    </div>
  );
}

// GOOD: Always wrap lazy components in a Suspense boundary
function App() {
  return (
    <div>
      <Suspense fallback={<p>Loading...</p>}>
        <Dashboard />
      </Suspense>
    </div>
  );
}

Mistake 3: Using a single Suspense boundary for unrelated lazy components

// BAD: One Suspense wraps everything — loading one component
// replaces the ENTIRE page with the fallback
function App() {
  return (
    <Suspense fallback={<p>Loading...</p>}>
      <Header />           {/* Already loaded */}
      <Sidebar />          {/* Already loaded */}
      <LazyMainContent />  {/* Loading... */}
      <LazyChat />         {/* Loading... */}
    </Suspense>
  );
  // When LazyMainContent loads, Header and Sidebar disappear
  // and the entire page shows "Loading..."
}

// GOOD: Use separate Suspense boundaries for independent sections
function App() {
  return (
    <>
      <Header />
      <Sidebar />
      <Suspense fallback={<p>Loading content...</p>}>
        <LazyMainContent />
      </Suspense>
      <Suspense fallback={<p>Loading chat...</p>}>
        <LazyChat />
      </Suspense>
    </>
  );
  // Header and Sidebar stay visible while content and chat load independently
}

Interview Questions

Q: What is code splitting and why does it matter for React apps?

Code splitting breaks a single large JavaScript bundle into smaller chunks that load on demand. It matters because browsers must download, parse, and execute all JavaScript before the app becomes interactive. A 2MB bundle on a 3G connection takes 10+ seconds. With code splitting, you ship only the code needed for the current view — typically reducing the initial bundle by 50-70%. The rest loads progressively as the user navigates. This directly improves Time to Interactive, Largest Contentful Paint, and user experience on slow connections.

Q: How does React.lazy() work under the hood?

React.lazy() takes a function that returns a Promise from a dynamic import(). When the lazy component first renders, React calls this function, which triggers the network request for the chunk. While the Promise is pending, React looks up the component tree for the nearest Suspense boundary and renders its fallback. When the Promise resolves with the module, React re-renders with the actual component. The module must have a default export that is a React component. Subsequent renders use the cached module — the chunk is only downloaded once.

Q: What is the difference between route-based and component-based code splitting?

Route-based splitting creates separate chunks for each page/route. It is the highest-impact optimization because pages are natural code boundaries and users only visit one page at a time. Component-based splitting targets heavy individual components within a page — chart libraries, rich text editors, PDF viewers, complex modals. Use route-based splitting as the default strategy for every app. Add component-based splitting when specific components are large (50KB+) and not immediately visible or not always used.

Q: How would you prevent the loading spinner from appearing during navigation?

Preload the chunk before the user navigates. The most common pattern is triggering the dynamic import on mouse hover over the navigation link — onMouseEnter={() => importPage()}. Since hover happens 200-500ms before click, the chunk downloads during that window. When the user clicks and the lazy component renders, the chunk is already cached, so Suspense never shows the fallback. Frameworks like Next.js do this automatically for Link components using Intersection Observer to preload chunks when links become visible in the viewport.

Q: What happens if a lazy-loaded chunk fails to download (network error)?

React.lazy() does not have built-in error handling for failed chunk loads. The failed Promise causes an unhandled error. To handle this, wrap the Suspense boundary in an Error Boundary component. The Error Boundary catches the error and renders a fallback UI — typically a "failed to load" message with a retry button. On retry, you can remount the lazy component, which triggers a fresh import() call. In production, chunk load failures happen when users have cached HTML pointing to old chunk filenames after a deployment. This is why some teams add a "new version available, please refresh" prompt.


Quick Reference — Cheat Sheet

CODE SPLITTING & LAZY LOADING
===============================

Dynamic import():
  Static:     import Component from "./Component"    // bundled at build time
  Dynamic:    import("./Component")                  // returns Promise, separate chunk
  Note:       Dynamic import is a JS feature, not React-specific
  Bundler:    Webpack/Vite sees import() and creates a separate chunk file

React.lazy():
  Define:     const Page = lazy(() => import("./Page"))
  Requires:   The module MUST have a default export (React component)
  Loads:      Only when the component first renders
  Caches:     After first load, subsequent renders use the cached module

Suspense:
  Wrap:       <Suspense fallback={<Loader />}><LazyComponent /></Suspense>
  Fallback:   Shown while the chunk is downloading
  Required:   Every lazy component MUST be inside a Suspense boundary
  Nesting:    Use multiple Suspense boundaries for independent loading states

Route-Based Splitting (most impactful):
  const Home = lazy(() => import("./pages/Home"))
  const Admin = lazy(() => import("./pages/Admin"))
  <Suspense fallback={<PageLoader />}>
    <Routes>
      <Route path="/" element={<Home />} />
      <Route path="/admin" element={<Admin />} />
    </Routes>
  </Suspense>

Component-Based Splitting (heavy features):
  const Chart = lazy(() => import("./ChartDashboard"))
  {showChart && (
    <Suspense fallback={<p>Loading chart...</p>}>
      <Chart />
    </Suspense>
  )}

Preloading on Hover:
  const importPage = () => import("./pages/Dashboard")
  const Dashboard = lazy(importPage)
  <Link onMouseEnter={() => importPage()}>Dashboard</Link>

+----------------------------+--------------------------------------+
| Strategy                   | When to Use                          |
+----------------------------+--------------------------------------+
| Route-based splitting      | Always — every multi-page app        |
| Component-based splitting  | Heavy components (50KB+), rare views |
| Preload on hover           | Critical navigation links            |
| Preload on viewport        | Links visible on screen (Next.js)    |
+----------------------------+--------------------------------------+

Error Handling:
  Wrap Suspense in an Error Boundary to catch chunk load failures
  Retry by remounting the lazy component (triggers fresh import())

What NOT to lazy-load:
  - Small components (<10KB) — HTTP overhead exceeds savings
  - Frequently used components — they belong in the main bundle
  - Above-the-fold content — it should render immediately

Previous: Lesson 8.2 — List Virtualization -> Next: Lesson 8.4 — Image & Asset Optimization ->


This is Lesson 8.3 of the React Interview Prep Course — 10 chapters, 42 lessons.

On this page