Home / Articles

Loading, Waiting, Failing: How lazy, Suspense and Error Boundaries Fit

Understand how React.lazy, Suspense and Error Boundaries split code, show fallbacks and contain crashes, and where to place each boundary for a better UX.

Publisher: REACTAPP.TOP9 min read1968 words

This article is published in English.

Sources: React Suspense, Lazy Loading & Error Boundaries — A Practical Guide

A React screen can be in three awkward states: its code is not downloaded, its content is not ready, or rendering has thrown. React.lazy defers code, Suspense shows a placeholder while something is pending, and an Error Boundary replaces a crashed subtree with a fallback. Once you see how they hand work to one another, you can split bundles sensibly and place boundaries that keep the rest of the page usable.

Deferring code with React.lazy

Lazy loading means a component's code is fetched only when React first needs to render it, not as part of the initial bundle. The difference is visible in the import: a static import Dashboard from "./Dashboard" pulls the module in immediately, while wrapping a dynamic import in lazy() lets the bundler emit a separate chunk. (In the snippet, the second import is shown for comparison only.)

import { lazy } from "react";

const Dashboard = lazy(() => import("./Dashboard"));

Instead of:

import Dashboard from "./Dashboard";

What lazy loading buys you

Picture an application with several distinct areas:

Application
├── Home
├── Products
├── Dashboard
├── Analytics
├── Admin
└── Payment

Someone landing on Home has no use for these yet:

Analytics
Admin
Payment

Shipping them anyway enlarges the initial download. With lazy loading, other chunks follow on demand:

Initial Load
     ↓
Home + common code
     ↓
User opens Dashboard
     ↓
Download Dashboard chunk
     ↓
Render Dashboard

How lazy works under the hood

lazy() accepts a function that returns a promise, almost always a dynamic import(). The target module must have a default export that is a component. The sequence looks like this (the diagram is conceptual, not code):

const Dashboard = lazy(() => import("./Dashboard"));

Conceptually:

lazy()
  ↓
dynamic import()
  ↓
JavaScript chunk requested
  ↓
Promise pending
  ↓
Component becomes available
  ↓
React renders component

Two practical rules follow. Declare lazy components at module top level, never inside another component, or each render creates a new component type and resets its state. And because the chunk arrives asynchronously, something must decide what the user sees meanwhile. That is the job of Suspense.

Showing a placeholder with Suspense

Suspense renders a fallback while any descendant is suspended, meaning it cannot render yet. Wrap the lazy component and pass the fallback element:

import { lazy, Suspense } from "react";

const Dashboard = lazy(() => import("./Dashboard"));

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

While the chunk downloads, React walks up to the nearest boundary and renders its fallback:

Dashboard
    ↓
Not ready
    ↓
Suspense
    ↓
"Loading dashboard..."

When the promise resolves, React tries the render again and the real content replaces the fallback:

Dashboard loaded
      ↓
React retries rendering
      ↓
Dashboard displayed

Suspense does not do the loading; lazy() (or a Suspense-enabled data library) triggers the work. Suspense only marks where a waiting state may appear and what it looks like.

Using lazy and Suspense together

Most real code pairs the two, usually with a richer fallback than a line of text:

const Dashboard = lazy(() => import("./Dashboard"));

function App() {
  return (
    <Suspense fallback={<DashboardSkeleton />}>
      <Dashboard />
    </Suspense>
  );
}

The full lifecycle of the first visit, again as a diagram:

User opens Dashboard
        ↓
React tries to render Dashboard
        ↓
Dashboard chunk isn't available
        ↓
Dynamic import starts
        ↓
Suspense fallback shown
        ↓
Dashboard chunk loads
        ↓
React retries rendering
        ↓
Dashboard displayed

Skeletons instead of spinners

A bare text fallback works, but it tells the user nothing about what is coming:

<Suspense fallback={<p>Loading...</p>}>

A skeleton component that mimics the final layout is usually a better choice:

<Suspense fallback={<DashboardSkeleton />}>

It reserves roughly the right space, reducing layout shift, and makes the wait feel shorter:

┌─────────────────────────────┐
│ Dashboard                   │
├─────────────────────────────┤
│ ░░░░░░░   ░░░░░░░           │
│ ░░░░░░░   ░░░░░░░           │
│                             │
│ ░░░░░░░░░░░░░░░░░░          │
└─────────────────────────────┘

Nesting Suspense boundaries

One boundary for the whole application is rarely what you want. Boundaries can be nested, and each suspended component is caught by the closest one above it:

<Suspense fallback={<PageSkeleton />}>
  <Header />

  <Suspense fallback={<ProductsSkeleton />}>
    <Products />
  </Suspense>

  <Suspense fallback={<RecommendationsSkeleton />}>
    <Recommendations />
  </Suspense>
</Suspense>

Instead of the entire page collapsing into a single loading message,

Entire page → Loading...

each section can reveal itself independently:

Header                  ✅

Products                Loading...
[Product Skeleton]

Recommendations         Loading...
[Recommendation Skeleton]

Containing failures with Error Boundaries

Waiting is one outcome; failing is another. Suppose a component throws while rendering:

function Dashboard() {
  throw new Error("Dashboard crashed!");
}

An uncaught render error unmounts the entire React root, leaving a blank page. An Error Boundary is a component that intercepts errors thrown by its descendants and shows fallback UI instead:

Dashboard
    ↓
Rendering Error
    ↓
Error Boundary
    ↓
Fallback UI

Using one looks like wrapping any other component, and the user sees a "Something went wrong" message instead of an empty screen:

<ErrorBoundary>
  <Dashboard />
</ErrorBoundary>

Writing an Error Boundary

Error Boundaries are still class components; there is no Hook equivalent in React itself. Two methods matter. The static getDerivedStateFromError runs during rendering and returns state that switches to the fallback. componentDidCatch runs afterwards with the error and info.componentStack, which is the place for logging or sending reports.

import React from "react";

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);

    this.state = {
      hasError: false,
    };
  }

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

  componentDidCatch(error, info) {
    console.error(error);
    console.error(info.componentStack);
  }

  render() {
    if (this.state.hasError) {
      return <h2>Something went wrong.</h2>;
    }

    return this.props.children;
  }
}

It is used exactly like the earlier example:

<ErrorBoundary>
  <Dashboard />
</ErrorBoundary>

This minimal version has no reset; a "Try again" button needs to set hasError back to false.

How React finds the boundary

Take a boundary around a profile component,

<ErrorBoundary>
  <Profile />
</ErrorBoundary>

where the component throws:

function Profile() {
  throw new Error("Profile failed");
}

React renders down the tree until the error occurs:

ErrorBoundary
      ↓
Profile
      ↓
💥 Error

It then searches upward for the nearest Error Boundary:

Profile
   ↑
ErrorBoundary

That boundary moves into its error state,

{
  hasError: true
}

and renders the fallback. A boundary cannot catch its own errors, only its children's; an error inside the fallback propagates to the next boundary up.

Errors a boundary catches

Boundaries handle errors thrown while React is rendering, in lifecycle methods, and in constructors of descendant components. A classic case is reading a property of undefined during render:

function Profile() {
  const user = undefined;

  return <h1>{user.name}</h1>;
}

They also catch a rejected lazy import. If a chunk fails to download, for example after a deploy removed the old file, the error surfaces at the nearest Error Boundary, not in Suspense.

Errors a boundary misses

Frequently asked in interviews: boundaries do not see errors in event handlers, because those run outside rendering.

function Button() {
  const handleClick = () => {
    throw new Error("Click failed");
  };

  return (
    <button onClick={handleClick}>
      Click
    </button>
  );
}

Handle these where they happen, with ordinary error handling in the handler:

try {
  // operation
} catch (error) {
  // handle error
}

The same applies to asynchronous callbacks such as timers or promise chains that throw after rendering has finished:

setTimeout(() => {
  throw new Error("Async error");
}, 1000);

If such an error should show the boundary's fallback, catch it and store it in state, then throw it during the next render.

Putting all three together

A production pattern wraps Suspense inside an Error Boundary:

<ErrorBoundary>
  <Suspense fallback={<DashboardSkeleton />}>
    <Dashboard />
  </Suspense>
</ErrorBoundary>

With the dashboard defined lazily,

const Dashboard = lazy(() => import("./Dashboard"));

the combined flow covers every outcome. Read the first branch as "code missing?": if so, React starts the import and shows the skeleton; afterwards the render either succeeds or reaches the Error Boundary.

                    Dashboard
                        │
                        ▼
                  Need the code?
                        │
               ┌────────┴────────┐
               │                 │
              YES                NO
               │                 │
          Render normally    dynamic import()
                                 │
                              Pending
                                 │
                                 ▼
                             Suspense
                                 │
                                 ▼
                            Skeleton UI
                                 │
                              Loaded
                                 │
                                 ▼
                           Render again
                                 │
                         ┌───────┴───────┐
                         │               │
                      Success          Error
                         │               │
                         ▼               ▼
                     Dashboard     Error Boundary
                                         │
                                         ▼
                                      Error UI

Splitting by route

Routes are the most natural split points, since each page is a self-contained screen. Declare each page lazily:

const Home = lazy(() => import("./pages/Home"));
const Products = lazy(() => import("./pages/Products"));
const Dashboard = lazy(() => import("./pages/Dashboard"));

then wrap the router in a boundary so navigation to an unloaded page shows a loader:

<Suspense fallback={<PageLoader />}>
  <Routes>
    <Route path="/" element={<Home />} />
    <Route path="/products" element={<Products />} />
    <Route path="/dashboard" element={<Dashboard />} />
  </Routes>
</Suspense>

Knowing what not to split

Lazy-loading a tiny component is counterproductive:

const Button = lazy(() => import("./Button"));

Each lazy component costs a request and a loading state, which a button never repays. Good candidates are large dashboards, analytics and admin areas, rich text editors, maps, heavy chart or third-party libraries, payment flows and rarely visited routes. Split code that is expensive or seldom used, not everything.

Placing boundaries deliberately

Suspense placement decides what disappears

A boundary high in the tree means any slow component replaces a large area with its fallback:

<Suspense fallback={<FullPageLoader />}>
  <EntireApplication />
</Suspense>

Placing it around the slow part keeps the header and footer visible and usable during loading:

<Header />

<Suspense fallback={<DashboardSkeleton />}>
  <Dashboard />
</Suspense>

<Footer />

Error Boundary placement decides what survives

The same logic applies to failures. A single boundary at the root turns any error into a full-page error screen:

<ErrorBoundary>
  <EntireApplication />
</ErrorBoundary>

Wrapping independent sections keeps a crash local:

<App>
  <Header />

  <ErrorBoundary>
    <Dashboard />
  </ErrorBoundary>

  <ErrorBoundary>
    <Recommendations />
  </ErrorBoundary>

  <Footer />
</App>

If recommendations fail, everything else keeps working:

Header              ✅
Dashboard           ✅
Recommendations     ❌
Footer              ✅

A root-level boundary remains useful as a last resort around the finer ones.

The mental model in one picture

             Lazy Loading
                  │
                  ▼
             Code needed?
                  │
                  ▼
             Suspense
                  │
           ┌──────┴──────┐
           │             │
        Loading         Ready
           │             │
        Fallback         ▼
                       Render
                         │
                   ┌─────┴─────┐
                   │           │
                Success       Error
                   │           │
                   ▼           ▼
                   UI    Error Boundary
                              │
                              ▼
                           Error UI

Key takeaways

  • React.lazy with dynamic import() splits code into chunks loaded on first render; the module needs a default export.
  • Suspense does not load anything; it defines where a fallback appears while descendants are suspended.
  • Error Boundaries catch render, lifecycle and constructor errors, including failed lazy imports, but not event handler or later async errors.
  • Suspense does not handle errors, so pair it with an Error Boundary.
  • Split at route level and around heavy, rarely used features, not tiny components.
  • Boundary placement is a UX decision: place both kinds around meaningful, independent sections.