Senior 5 min · March 06, 2026

React Suspense — Missing ErrorBoundary Took Down Checkout

A CDN timeout on a lazy chunk caused a blank checkout page — Suspense doesn't catch errors.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • React.lazy + Suspense defer component loading until render time
  • Code splitting via dynamic import() breaks the bundle into async chunks
  • Suspense shows a fallback UI while the lazy chunk loads
  • Performance gain: up to 60% smaller initial bundle on route-heavy apps
  • Production trap: missing ErrorBoundary causes a white screen when chunk fails
  • Biggest mistake: assuming Suspense handles data fetching in React 17 — it doesn't
Plain-English First

Imagine a restaurant that doesn't cook every dish before you arrive — they only start your order once you sit down and ask for it. React Lazy Loading works exactly the same way: instead of downloading every part of your app the moment someone visits, React waits and only fetches the code for a page or component when the user actually needs it. Suspense is the 'please wait, your food is being prepared' sign the waiter puts on your table — it shows a fallback UI while the real component loads. Together they stop your app from making the user download a giant bundle upfront, just like a good restaurant doesn't make you pay for dishes you never ordered.

Bundle size is the silent killer of React app performance. A typical single-page application compiled without any code-splitting hands the browser a monolithic JavaScript file — sometimes several megabytes — before rendering a single pixel. On a 4G connection that feels slow; on a 3G connection in rural areas or emerging markets it feels broken. Google's Core Web Vitals measure this directly: a high Time-to-Interactive score tanks your SEO ranking and drives users away inside three seconds. React Suspense and lazy loading are the framework-level answer to this problem, and they're more powerful — and more nuanced — than most tutorials show.

Before React.lazy and Suspense, code-splitting meant manually wiring Webpack dynamic imports, writing your own loading-state logic per component, and scattering conditional renders across your codebase. Every team solved it differently, which meant every codebase looked different and bugs crept in at the seams. React 16.6 introduced React.lazy and Suspense to give the framework itself ownership of asynchronous component resolution, and React 18 extended Suspense to cover data fetching — turning it into a first-class primitive for anything that takes time.

By the end of this article you'll understand exactly what happens inside React's reconciler when a lazy component suspends, how to structure route-level and component-level splits for maximum impact, which edge cases can silently break your fallback UI in production, and how to combine Suspense with React 18's concurrent features like startTransition to build apps that feel instant. You'll walk away with production-ready patterns, not toy examples.

What is React Suspense and Lazy Loading?

React.lazy and Suspense are a declarative API for code splitting. React.lazy takes a function that returns a dynamic import() and wraps it in a lazy component. Suspense is a component that renders a fallback while the lazy component's chunk is loading.

React.lazy and Suspense are not the same as manual dynamic imports. The key difference: React.lazy integrates with the reconciler so that when a lazy component renders, React automatically fetches the chunk, suspends the tree, and shows the fallback. Manual dynamic import() requires you to manage loading state yourself with useState and useEffect.

But there's a catch: React.lazy only works in environments that support dynamic import() natively (bundlers like Webpack, Vite, Parcel). It does nothing in server-side rendering without additional setup — see Suspense on the server section.

LazyDashboard.jsxJSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import React, { Suspense, lazy } from 'react';

const Dashboard = lazy(() => import('./Dashboard'));
// Webpack will create a separate chunk for Dashboard.js

function App() {
  return (
    <div>
      <h1>My App</h1>
      <Suspense fallback={<div>Loading dashboard...</div>}>
        <Dashboard />
      </Suspense>
    </div>
  );
}

export default App;
Mental Model: Lazy as a Promise Wrapper
  • React.lazy internally stores the promise from the dynamic import.
  • When the component renders, React checks if the promise has resolved. If not, it throws a promise (yes, literally throws it).
  • Suspense catches that thrown promise and renders the fallback.
  • When the promise resolves, React re-renders the lazy component with the actual module.
  • If the promise rejects, React throws the error — which Suspense does NOT catch. That's why ErrorBoundary is required.
Production Insight
Suspense does NOT catch errors from failed chunks.
Thrown rejection by React.lazy propagates up — without an ErrorBoundary, your component tree disappears.
Rule: Every Suspense boundary must have a parent ErrorBoundary.
Key Takeaway
React.lazy is a declarative wrapper for dynamic import().
Suspense handles the loading state; it does NOT handle errors.
Always pair with an ErrorBoundary.
Should you code-split this component?
IfComponent is > 30KB gzipped
UseLazy-load it — strong candidate for route-level split.
IfComponent is < 5KB
UseDo not lazy-load — the overhead of a new request + Suspense fallback outweighs any benefit.
IfComponent is often prefetched via <link rel=prefetch> or user will likely visit it
UseLazy-load with preload hint: <link rel=preload as=script href=chunk.js> in the parent route.
IfComponent is inside a tight loop or heavy re-render tree
UseDo not lazy-load — it will cause repeated Suspense fallbacks. Inline or use prefetch.
IfComponent is part of an admin panel only 10% of users ever see
UseStrong lazy-load candidate — saves 200KB+ for 90% of users.

How React.lazy and Suspense Work Internally

Under the hood, React.lazy creates a special component type that the reconciler treats differently. When the reconciler encounters a lazy component, it checks a hidden __status property. If the status is 'pending', React throws the promise object (a caught promise). This triggers Suspense to catch it and render the fallback. Once the promise resolves, React marks the status as 'resolved' and schedules a re-render. On the next render, the lazy component's render function is called normally.

This mechanism is the basis for all Suspense-based data fetching in React 18. The key insight: throwing a promise is the core primitive. Any library that wants to integrate with Suspense (like Relay or SWR) can throw a promise to let Suspense manage the loading state.

The reconciler also respects concurrent features: if you wrap a state update that triggers a Suspense in startTransition, React can keep showing the old UI while the new chunk loads, avoiding a loading flash.

react-lazy-internals.jsxJSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// Pseudocode of React.lazy internal behaviour (simplified)
function lazy(load) {
  let status = 'pending';
  let result;
  let promise = load().then(
    (module) => {
      status = 'resolved';
      result = module.default;
    },
    (error) => {
      status = 'rejected';
      result = error;
    }
  );

  return function LazyComponent(props) {
    if (status === 'pending') {
      throw promise;  // This is caught by the nearest Suspense boundary
    }
    if (status === 'rejected') {
      throw result;   // This is NOT caught by SuspenseErrorBoundary needed
    }
    return createElement(result, props);
  };
}
Key insight: Throwing a Promise
React uses a special error boundary mechanism internally. When a component throws a thenable (an object with a .then method), Suspense treats it as a request to wait. This is not a standard React API — it's a convention that the reconciler understands. Your own components should never throw promises directly; always use React.lazy or a Suspense-compatible data library.
Production Insight
React.lazy's thrown promise is caught by the nearest Suspense boundary — only one level up.
If you have nested Suspense, the closest one handles it.
But a thrown error propagates all the way up — you must have an ErrorBoundary above the top Suspense.
Key Takeaway
Reconciler checks lazy component status before render.
Pending -> throw promise -> caught by Suspense.
Rejected -> throw error -> caught by ErrorBoundary.

Route-Level vs Component-Level Code Splitting

The most effective code-splitting pattern is route-level: split by URL. Each route gets its own lazy chunk. In React Router, you wrap your route elements with lazy imports.

Component-level splitting is more granular — you lazy-load a heavy component inside a page, like a data grid or chart. This is useful when a page has one heavy element that most users never need, but it adds complexity: you now have multiple Suspense boundaries on one page, and must coordinate their loading states carefully.

The rule of thumb: start with route-level splitting. Then profile: if a single component on a page accounts for more than 30% of the page bundle size and only 20% of users interact with it, component-level split it. Otherwise, don't.

RouteLevelSplitting.jsxJSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { lazy, Suspense } from 'react';

const Home = lazy(() => import('./routes/Home'));
const Dashboard = lazy(() => import('./routes/Dashboard'));
const Reports = lazy(() => import('./routes/Reports'));

function App() {
  return (
    <BrowserRouter>
      <Suspense fallback={<div>Loading page...</div>}>
        <Routes>
          <Route path="/" element={<Home />} />
          <Route path="/dashboard" element={<Dashboard />} />
          <Route path="/reports" element={<Reports />} />
        </Routes>
      </Suspense>
    </BrowserRouter>
  );
}
Warning: Component-level splitting inside a route with its own Suspense
If you already have a route-level Suspense wrapping the entire route tree, adding a component-level Suspense inside a lazy route creates nested fallbacks. The outer fallback may show briefly while the route chunk loads, then the inner fallback shows while the component loads. To avoid this, use a single Suspense per visible region. Or use startTransition to defer the component loading and keep the old route visible.
Production Insight
Route-level splitting with a single Suspense at the router level is simplest.
Component-level splitting inside a route that already has Suspense can cause double spinners.
Measure with React DevTools profiler to see if fallbacks overlap.
Key Takeaway
Route-level split first, component-level split only after profiling.
Avoid nested Suspense boundaries — they flash multiple fallbacks.
One visible region = one Suspense boundary.
Route-level or component-level?
IfEntire page view changes (e.g., /home vs /settings)
UseRoute-level split. Use one Suspense boundary per route.
IfA heavy modal or chart only used by 10% of users on this page
UseComponent-level split. Lazy-load the heavy piece inside the page.
IfMultiple heavy components on one page that load at once
UseConsider bundling them into a single chunk or using a single Suspense with a single lazy import that imports all.
IfThe same component is used by multiple routes
UseDo not lazy-load it per route — duplicate chunks. Either keep it in a shared chunk or use a shared import() that returns the same module.

Error Handling with ErrorBoundaries and Suspense

Suspense does not catch errors. When a lazy component's import fails (network error, CDN fails, module syntax error), the promise rejects, and React.lazy throws an error. That error propagates up the component tree until an ErrorBoundary catches it. If no ErrorBoundary exists, React unmounts the entire tree and logs an error — but the user sees a white screen.

Best practice: every Suspense boundary that encloses a lazy component must have a corresponding ErrorBoundary as its parent. Not a global one — at least one per route or per major feature. This way, if a chunk fails for one route, other routes still work.

ErrorBoundaries are React components that implement componentDidCatch or static getDerivedStateFromError. They cannot catch errors in event handlers or asynchronous code (outside render), but they do catch errors thrown during render — exactly what React.lazy does.

ErrorBoundaryWrapper.jsxJSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import { Component } from 'react';

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

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

  componentDidCatch(error, info) {
    console.error('Error caught by boundary:', error, info);
    // Send to error reporting service
  }

  render() {
    if (this.state.hasError) {
      return (
        <div role="alert">
          <h2>Something went wrong loading this section.</h2>
          <p>{this.state.error.message}</p>
          <button onClick={() => window.location.reload()}>Retry</button>
        </div>
      );
    }
    return this.props.children;
  }
}

// Usage
<ErrorBoundary fallback={<ErrorUI />}>
  <Suspense fallback={<Loading />}>
    <LazyComponent />
  </Suspense>
</ErrorBoundary>
Tip: Multiple ErrorBoundaries
Don't put one ErrorBoundary at the root for the whole app. If the footer chunk fails, you don't want to crash the entire page. Create granular boundaries: one for the header, one for the main content area, one for the footer. This is known as the 'crash isolation' pattern.
Production Insight
If your lazy chunk fails and no ErrorBoundary exists, the entire app unmounts.
Users see a white screen with no way to recover — your bounce rate spikes.
Always pair every Suspense with an ErrorBoundary, and include a retry button.
Key Takeaway
Suspense only handles loading — it rejects errors upward.
Wrap each Suspense in its own ErrorBoundary.
Isolate crashes at feature level, not global.

Suspense for Data Fetching in React 18

React 18 extended Suspense to cover data loading. Now any component can 'suspend' by throwing a promise. Libraries like Relay, SWR, and TanStack Query can integrate. The benefit: you get a unified loading UI, automatic race condition handling (React will ignore outdated requests), and the ability to use startTransition to keep showing stale data while new data loads.

This pattern eliminates the need for loading states scattered across components. Instead of checking isLoading in every component, you wrap the tree in Suspense and let React handle it. Combined with Streaming Server Rendering, you can send HTML as the data arrives.

But there's a trade-off: Suspense for data fetching requires the data fetching to be done at the component level (not in useEffect). Libraries like Relay and SWR already support this; if you're using custom fetch logic, you'll need to wrap it in a Suspense-enabled data source.

DataFetchingWithSuspense.jsxJSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Using SWR with Suspense mode
import useSWR from 'swr';
import { Suspense } from 'react';

function UserProfile() {
  const { data } = useSWR('/api/user', fetcher, { suspense: true });
  return <div>{data.name}</div>;
}

function App() {
  return (
    <Suspense fallback={<div>Loading user profile...</div>}>
      <UserProfile />
    </Suspense>
  );
}

// Without a library: wrap your fetch in a promise-throwing wrapper
// Not recommended — use a Suspense-enabled library instead.
Suspense data fetching changes the mental model
Instead of 'fetch data → set state → show loading → show data', you write 'render component → if data not ready, throw promise → React handles the rest'. This removes loading state duplication and race conditions. But it only works if every data fetch in the tree suspends — mixing Suspense and non-Suspense components can cause waterfalls. Wrap the entire data-dependent tree in a single Suspense boundary.
Production Insight
Suspense for data fetching eliminates loading spinners — but only if all data-loading components use it.
Mixing Suspense and non-Suspense data fetching can cause waterfall: some components finish, others still suspend.
Use startTransition to avoid showing fallback when refetching data on navigation.
Key Takeaway
React 18 Suspense works for both code and data.
Use Suspense-enabled fetching libraries (Relay, SWR) — avoid custom promise throwing.
startTransition keeps stale UI while new data loads — better UX.

Production Gotchas and Performance Optimisations

Even with proper Suspense setup, several traps await in production:

  1. Service worker caching — If a chunk URL changes (e.g., hash update), but the service worker serves the old chunk, you may get a stale module with wrong exports. Use versioned chunk names and always update the service worker cache.
  2. Font and CSS loading — Suspense only handles JavaScript modules. Your lazy-loaded component may depend on CSS-in-JS or custom fonts that haven't loaded yet, causing a flash of unstyled content. Preload fonts and critical CSS.
  3. Multiple Suspense boundaries on one page — Each boundary triggers a separate loading state. Users may see several spinners pop in and out. Combine related components under one Suspense boundary.
  4. Lazy loading of components that are already in the bundle — If you lazy-import a component that's already available in the same chunk (e.g., from a barrel export), you gain nothing. Ensure the import points to a separate file.
  5. Prefetching and preloading — For routes the user is likely to navigate to (e.g., next page in a wizard), use <link rel=prefetch> or dynamic import() with a low priority to load the chunk before navigation.
PrefetchOnHover.jsxJSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// Prefetch a lazy chunk when user hovers over a link
import { lazy, Suspense, useEffect } from 'react';

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

function LinkWithPrefetch({ to, children }) {
  const prefetch = () => {
    const mod = import('./NextPage'); // start loading chunk
  };

  return (
    <a
      href={to}
      onMouseEnter={prefetch}
      onTouchStart={prefetch}
    >
      {children}
    </a>
  );
}
Warning: Lazy loading inside event handlers triggers on every event
If you call lazy(() => import(...)) inside an onClick handler, you'll create a new lazy component on every click, causing a new chunk download each time. Instead, define the lazy component at the top of the module and only conditionally render it.
Production Insight
Prefetching on hover cuts perceived load time by 200-400ms for next page.
But too many prefetches can saturate user's network — limit to 1-2 prefetches.
Use <link rel=prefetch> for critical next routes, <link rel=preload> for assets.
Key Takeaway
Chunk version mismatches with service workers cause silent failures.
Prefetch on interaction, not on mount.
Don't lazy-load what's already in the bundle.
● Production incidentPOST-MORTEMseverity: high

The Blank Screen: A Missing ErrorBoundary Took Down our Checkout

Symptom
Users reported the checkout page was completely blank on mobile while on a 3G connection. Desktop users with stable Wi-Fi never saw it.
Assumption
The team assumed that because the app had route-level Suspense with a spinner, any loading error would be caught by the spinner or the browser's native error page.
Root cause
The lazy chunk for the payment form failed to load due to a CDN timeout. React.lazy throws a rejected promise, which Suspense does not catch — only an ErrorBoundary does. No ErrorBoundary existed around that Suspense boundary, so the error propagated unhandled and React unmounted the entire component tree.
Fix
Wrap every Suspense boundary with a dedicated ErrorBoundary that provides a meaningful fallback UI (e.g., 'Payment form failed to load — please refresh or try again'). Add a retry button or auto-retry logic.
Key lesson
  • Suspense is not an error handler — always pair it with an ErrorBoundary.
  • Test on throttled connections: simulate chunk failures with DevTools offline mode.
  • Every route-level or feature-level Suspense boundary needs its own ErrorBoundary, not a global one.
  • Include a retry mechanism that reloads the failed chunk (e.g., window.location.reload() or dynamic import retry).
Production debug guideCommon failure patterns and how to identify them fast4 entries
Symptom · 01
Component never loads — fallback stays forever
Fix
Open browser network tab. Check if chunk (.js) request is pending or 404. Verify the chunk path matches the build output. Forced reload may help if service worker cached a stale path.
Symptom · 02
Fallback flickers — brief flash of loading UI every time
Fix
The lazy chunk is too small. Set a minimum delay in your fallback or debounce the Suspense transition. Alternatively, avoid lazy-loading tiny components — inline them.
Symptom · 03
User sees blank screen on navigation
Fix
Check for an uncaught error in console. Add a Console.ErrorBoundary that logs details. Most likely the lazy chunk threw an error (network, parse, or module mismatch). Wrap with ErrorBoundary.
Symptom · 04
Multiple spinners appear at once
Fix
Too many Suspense boundaries on one page. Consolidate them. Use one Suspense for a whole route section and place your ErrorBoundary at the same level.
★ Quick Debug: Suspense & Lazy LoadingRun these steps when a lazy component fails to load or the UI breaks after a code split.
Chunk fails to load (404 or network error)
Immediate action
Check browser console for 'Loading chunk X failed'. Open Network tab and look for failed .js request.
Commands
document.querySelector('script[src*="myLazyChunk"]') — verify if script tag exists
window.__webpack_require__.c — inspect webpack module registry (dev only)
Fix now
Force re-fetch: delete cached service worker, hard reload, or use retry logic: import(./MyComponent?t=${Date.now()})
Fallback shows briefly on every render+
Immediate action
Check if the component is tiny (< 5KB). If yes, avoid lazy-loading it.
Commands
console.log('Chunk size:', (await import('./MyComponent')).default?.toString().length)
// Check if the component is being dynamically imported in a loop or every render
Fix now
Move import outside of render. Wrap with useMemo or use lazy only for route-level splits.
White screen after navigation with no error in console+
Immediate action
React may have unmounted everything due to an uncaught error. Add an ErrorBoundary at the root.
Commands
window.onerror = (msg) => console.error('Global error:', msg)
React.createElement('div', null, 'Error boundary test') — simulate?
Fix now
Wrap your top-level Suspense with <ErrorBoundary fallback={<div>Something went wrong</div>}>
React.lazy vs Manual Dynamic Import
AspectReact.lazy + SuspenseManual dynamic import()
Loading state managementAutomatic — Suspense handles fallbackYou manage with useState + useEffect
Error handlingRequires ErrorBoundary outside SuspenseYou can catch in the .catch() of the promise
Nested/conditional loadingComponent must be rendered to trigger loadYou can call import() anywhere (on hover, after timeout)
Performance overheadMinimal — reconciler bypasses lazy until resolvedSame overhead on network request
Server-side renderingRequires extra setup (React.lazy is not SSR-friendly without streaming)Works without Suspense on server if you await the import
Code splitting granularityComponent-level onlyModule-level (any file, not just components)

Key takeaways

1
React.lazy + Suspense is the declarative way to code-split components; it works by throwing promises caught by Suspense.
2
Suspense only handles loading
errors from lazy imports must be caught by an ErrorBoundary.
3
Route-level splitting is usually more impactful than component-level; profile before micro-splitting.
4
React 18 Suspense extends to data fetching, but requires Suspense-enabled libraries (SWR, Relay).
5
Always test on throttled connections and simulate chunk failures to catch missing ErrorBoundaries.
6
Prefetch on user interaction (hover, touch) to make lazy loads feel instant without wasting bandwidth.

Common mistakes to avoid

4 patterns
×

Missing ErrorBoundary around Suspense

Symptom
When a chunk fails to load, entire app shows blank screen. No user-facing error. Users close the tab.
Fix
Wrap every Suspense boundary with a dedicated ErrorBoundary that shows a fallback UI and retry button.
×

Lazy-loading tiny components (< 5KB)

Symptom
Brief flash of loading spinner on every navigation because the chunk is too small to see the response delay.
Fix
Only lazy-load components > 30KB. For smaller ones, inline them or use a non-lazy dynamic import that doesn't trigger Suspense.
×

Using lazy inside a conditional or loop that creates new instances

Symptom
Every re-render or iteration creates a new lazy component, causing repeated chunk downloads and Suspense re-triggers.
Fix
Define lazy components at module top-level outside of any function. Use conditional rendering to decide whether to show them.
×

Forgetting to handle the case where lazy import returns a non-default export

Symptom
The lazy component renders nothing — React.lazy expects .default. If your module exports named exports, you get undefined.
Fix
Ensure the imported module uses export default or wrap it: lazy(() => import('./Module').then(m => ({ default: m.NamedComponent })))
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
What happens internally when React encounters a lazy component that hasn...
Q02SENIOR
Why does Suspense not catch errors from React.lazy? How do you handle th...
Q03JUNIOR
How do you use React.lazy with React Router for route-level splitting? W...
Q04SENIOR
What is startTransition and how does it relate to Suspense?
Q05SENIOR
Can you use React.lazy with server-side rendering? What are the limitati...
Q01 of 05SENIOR

What happens internally when React encounters a lazy component that hasn't loaded yet?

ANSWER
React.lazy stores the promise returned by the dynamic import. During render, the reconciler checks the internal status. If it's pending, it throws the promise. The nearest Suspense boundary catches it and renders the fallback. When the promise resolves, the lazy component's status changes to resolved, and React schedules a re-render with the actual component.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
What is the difference between React.lazy and dynamic import() in plain JavaScript?
02
Can I use React.lazy with class components?
03
Does React.lazy work with TypeScript?
04
How do I test lazy-loaded components in unit tests?
05
Is React.lazy safe for accessibility?
🔥

That's React.js. Mark it forged?

5 min read · try the examples if you haven't

Previous
React Error Boundaries
17 / 47 · React.js
Next
React Server Components