What Is React.lazy and How Does Suspense Work?

Advanced12 min interview
Skills tested:
Explaining the relationship between React.lazy, dynamic import(), and bundler code splittingUnderstanding what Suspense renders during the chunk download and how it resumesPairing Suspense with an ErrorBoundary for chunk load failuresAdapting named exports for React.lazy with a .then() wrapperKnowing that React.lazy requires default exports and what the runtime error looks like without them

Advertisement

🧩 Scenario

React.lazy and Suspense are a standard performance topic. Interviewers look for whether you understand the three-state model (pending/resolved/rejected), the ErrorBoundary requirement, and the named export gotcha.

Architecture Walkthrough

How React.lazy and Dynamic import() Work Together

React.lazy does not do code splitting on its own — it is a React-level wrapper around the bundler's code splitting primitive: the dynamic import() function. When webpack, Vite, or another bundler encounters a dynamic import() call, it creates a separate output chunk file for that module. That chunk is not included in the main bundle and is not downloaded on initial page load.

React.lazy(() => import('./AdminPanel')) stores the import function without calling it. When React first encounters this lazy component during rendering, it calls the import function, which triggers a network fetch for the chunk file. While the fetch is in flight, React throws a Promise (this is the internal protocol Suspense uses), which causes the nearest Suspense boundary to show its fallback. When the Promise resolves (the chunk is loaded), React resumes rendering the component from its actual code.

The Three-State Model

A lazy component can be in three states. Pending: the chunk is being downloaded; Suspense shows its fallback. Resolved: the chunk loaded successfully; React renders the component normally. Rejected: the chunk failed to load (network error, bad URL, deploy-time cache busting); React throws an error that an ErrorBoundary can catch. Without an ErrorBoundary, a rejected chunk crashes the Suspense subtree and shows a blank screen.

Named Export Limitation

React.lazy requires the dynamic import to resolve to a module with a default export. The module's shape must be { default: ComponentType }. Named exports (export const MyComponent = ...) do not satisfy this. The adaptation is a .then() call that reshapes the module: lazy(() => import('./module').then(m => ({ default: m.MyComponent }))). Without this, the lazy component is undefined and React throws immediately on render.


Key Code Explained

import { lazy, Suspense } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { BrowserRouter, Routes, Route } from 'react-router-dom';

// Default exports: direct use
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Analytics = lazy(() => import('./pages/Analytics'));

// Named export: .then() wrapper required
const RevenueChart = lazy(() =>
  import('./charts/revenue').then((module) => ({
    default: module.RevenueChart,  // wrap named export as default
  }))
);

// Route-level lazy loading: entire page is a separate chunk
function App() {
  return (
    <BrowserRouter>
      {/*
        ErrorBoundary catches failed chunk loads.
        Without it, a chunk network error shows a blank screen.
      */}
      <ErrorBoundary
        fallbackRender={({ error, resetErrorBoundary }) => (
          <div>
            <p>Failed to load this page: {error.message}</p>
            <button onClick={resetErrorBoundary}>Try again</button>
          </div>
        )}
      >
        {/*
          Suspense shows the fallback while any lazy component in this
          subtree is loading. Fallback disappears when the chunk loads.
        */}
        <Suspense fallback={<PageSkeleton />}>
          <Routes>
            <Route path="/dashboard" element={<Dashboard />} />
            <Route path="/analytics" element={<Analytics />} />
          </Routes>
        </Suspense>
      </ErrorBoundary>
    </BrowserRouter>
  );
}


// Component-level lazy loading: only one section is deferred
function AnalyticsPage() {
  const [showChart, setShowChart] = useState(false);

  return (
    <div>
      <h1>Analytics</h1>
      <p>Summary text always renders immediately</p>

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

      {showChart && (
        <ErrorBoundary fallbackRender={({ resetErrorBoundary }) => (
          <button onClick={resetErrorBoundary}>Retry chart</button>
        )}>
          {/* Chunk fetched only when showChart becomes true */}
          <Suspense fallback={<ChartSkeleton />}>
            <RevenueChart />
          </Suspense>
        </ErrorBoundary>
      )}
    </div>
  );
}


// Verifying code splitting works: check the Network tab
// Before: one large bundle.js
// After: bundle.js + Dashboard.chunk.js, Analytics.chunk.js
// Dashboard.chunk.js should appear in the Network tab only when /dashboard is visited

// Preloading: reduce the loading delay for likely-to-be-needed components
function NavLink({ to, label }: { to: string; label: string }) {
  const handleMouseEnter = () => {
    // Trigger the chunk download before the user clicks
    // import() is idempotent — calling it multiple times resolves from cache
    switch (to) {
      case '/dashboard': import('./pages/Dashboard'); break;
      case '/analytics': import('./pages/Analytics'); break;
    }
  };

  return (
    <Link to={to} onMouseEnter={handleMouseEnter}>
      {label}
    </Link>
  );
}

The resetErrorBoundary from react-error-boundary resets the boundary state, which causes React to re-attempt rendering the lazy component and re-trigger the import(). If the user was on a slow connection and the chunk failed, clicking "Try again" retries the network request. This is the correct retry mechanism for chunk load failures.


Tradeoffs

Lazy loading scopeBundle impactFirst-visit delayComplexity
No code splittingLarge initial bundleNoneNone
Route-level splittingModerate reductionVisible on first route visitLow
Component-level splittingLarger reductionOnly when component is first shownLow
Preloading on hoverSame as aboveOften eliminatedLow

What Interviewers Actually Check

  • Whether you know Suspense is required (not optional) and what happens without it
  • Whether you know the three-state model: pending/resolved/rejected
  • Whether you pair Suspense with ErrorBoundary for chunk failures
  • Whether you know the default export requirement and the .then() adaptation
  • Whether you know React.lazy is client-only and Next.js has a different solution for SSR

Follow-Up Questions

  1. How does Next.js dynamic() differ from React.lazy? What does it add for SSR?
  2. How do you verify in the browser that code splitting is working correctly?
  3. When would you place the Suspense boundary at the component level instead of the route level?
  4. How does React 18 streaming SSR use Suspense differently from client-side code splitting?
  5. If a lazy component fails to load, how does resetErrorBoundary from react-error-boundary let the user retry?

Common Candidate Mistakes

  • Not wrapping in Suspense — React throws immediately with a clear message, but candidates forget this
  • Not adding an ErrorBoundary — users get a blank screen when CDN is down or a chunk URL changes after deploy
  • Using React.lazy with a named export and not understanding why the component is undefined at runtime
  • Placing the Suspense at the very root — one slow lazy component shows a fallback for the entire page
  • Not knowing the Network tab is how you verify code splitting is working

Interview Readiness Checklist

Before you leave this question, make sure you can answer:

  • Can you explain the three states of a lazy component and what renders in each?
  • Can you implement React.lazy with Suspense and an ErrorBoundary?
  • Can you explain why React.lazy requires a default export?
  • Can you adapt a named export with a .then() wrapper?
  • Can you explain how to verify code splitting is working in the browser?

Summary

React.lazy wraps a dynamic import() call, telling the bundler to split that component into a separate chunk file. The chunk is not included in the initial bundle and is fetched only when the lazy component is first encountered during rendering. While the fetch is in flight, React throws a Promise that the nearest Suspense boundary intercepts, showing its fallback. When the chunk loads, React resumes and renders the component. If the chunk fails to load, an ErrorBoundary is needed to catch the error and show a retry option; without one, chunk failures produce a blank screen. React.lazy requires the import to resolve to a module with a default export; use a .then() wrapper to adapt named exports. React.lazy is client-only; for SSR use Next.js dynamic().

Frequently Asked Questions

Can React.lazy be used on the server (SSR)?

React.lazy and Suspense for code splitting are client-only in the classic sense. For SSR, use frameworks like Next.js with its dynamic() function, which handles SSR fallbacks correctly. React 18 streaming SSR uses Suspense differently — for server-side data fetching boundaries, not just code splitting.

Advertisement


Stay Updated

Get the latest frontend challenges, interview questions and tutorials delivered to your inbox.

Advertisement