engineering

What is React Suspense?

What is React Suspense?

Let's explore React Suspense — what it is, how it is used, what improvements does it offer, how to use it with code splitting and error boundaries, and more.

Kaushal Joshi

Kaushal Joshi

May 22, 2025 12 min read

When building modern React apps, we often end up juggling multiple loading states, async data fetching, nested fallbacks, and a lot more. At some point, managing everything individually becomes very hectic. After all, you should spend more time working on business logic, shipping new features, and building beautiful interfaces while performance and optimization come along the way... right?

React Suspense is a powerful feature introduced in React 16 that makes loading experiences in your app smooth and fluid. In this article, let's dive deep into React Suspense. We'll start with what React Suspense is, explore its core features, understand how it works under the hood, and learn about error boundaries.

Prerequisites and Assumptions

This article covers an advanced topic in the React ecosystem. Therefore, if you're just starting with React, I'd recommend bookmarking this blog and reading it later. You can check out my other blogs on React fundamentals and React hooks, which would help you become a better React developer. You can find them here.

While writing this article, I assume you're already familiar with:

  • React component lifecycle and hooks

  • JavaScript Promises and async/await

  • Basic understanding of code splitting

  • React's rendering process

What is React Suspense?

React Suspense allows components to wait for something before rendering. In most cases, it waits for the completion of async operations like data fetching or code splitting. Simply put, it checks if a component is ready to be rendered and displays a fallback UI if it's not.

Initially, it was introduced for lazy loading components with React.lazy(). Eventually, the React team realized there are more use cases for Suspense, so they expanded support for data fetching with libraries like Relay, React Query, and for use with React Server Components.

Features of React Suspense

When we talk about React Suspense, there are several features that make it powerful. Let's go through the most common scenarios where React Suspense improves user experience:

  1. Declarative async UI: It defines fallback UIs in your component tree. These components are shown when your component is loading or throws an error.

  2. Code splitting with React.lazy(): You can lazy load components that are rendered dynamically (pages, routes, modals, etc.) and render them with React Suspense.

  3. Concurrent rendering support: React Suspense works seamlessly with React 18's concurrency features.

  4. Error boundaries + Suspense boundaries: You can handle both error and loading states in fewer lines of code.

  5. Integration with data libraries: Works beautifully with third-party data fetching libraries like React Query and Relay. It also complements the latest React Server Components.

In this article, we will not only learn React Suspense, but also learn about its implementation along with React.lazy(), error boundaries, etc.

How React Suspense Works

React Suspense works by suspending the rendering of a component when it throws a Promise. React catches the thrown Promise, shows the fallback UI, and then retries rendering when the promise resolves. If the promise fulfills, it renders the component itself. If it rejects, it renders the error UI. The error UI is shown only if it is wrapped inside an error boundary.

The most interesting aspect is that everything happens declaratively. You can write code for the component to be rendered, the parent component, fallback component, and error component separately and use them together. Let's see a simple example:

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

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

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

Suppose the <UserProfile /> component fetches data before displaying it on the screen. While the data is being fetched, Loading... will be shown. The fallback prop accepts any valid React node, so you can create a dedicated UI component for loading states.

Error Boundaries and React Suspense

React Suspense handles loading states, but not errors. If a lazy-loaded component or data fetching component throws an actual error (not just a promise), it won't be caught by Suspense. That's where React error boundaries come into the picture.

To create an error boundary, you must create a class component like the following:

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

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

  componentDidCatch(error, errorInfo) {
    // You can log the error to an error reporting service
    console.error("Error caught by boundary:", error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      // Here you can render custom error UI
      return this.props.fallback || <h2>Something went wrong.</h2>;
    }

    return this.props.children;
  }
}

You can wrap <Suspense> with <ErrorBoundary /> like this:

<ErrorBoundary fallback={<p>Failed to load profile</p>}>
  <Suspense fallback={<p>Loading profile...</p>}>
    <UserProfile />
  </Suspense>
</ErrorBoundary>

Use Cases for React Suspense

Let's understand some real-world scenarios where using React Suspense makes a difference. There's a high chance that you can currently be writing a component that can be improved significantly by using these methods.

1. Code Splitting

React Suspense works wonders for JavaScript bundle size when components are lazy loaded. Components imported with React.lazy() are imported dynamically only when needed, making them excluded from the main bundle initially sent to the client. This improves initial load time.

import React, { Suspense, lazy } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import NavBar from './components/NavBar';
import LoadingSpinner from './components/LoadingSpinner';

const HomePage = lazy(() => import('./pages/HomePage'));
const Dashboard = lazy(() => import('./pages/Dashboard'));
const UserProfile = lazy(() => import('./pages/UserProfile'));
const Settings = lazy(() => import('./pages/Settings'));

function App() {
  return (
    <BrowserRouter>
      <NavBar />
      <Suspense fallback={<LoadingSpinner />}>
        <Routes>
          <Route path="/" element={<HomePage />} />
          <Route path="/dashboard" element={<Dashboard />} />
          <Route path="/profile" element={<UserProfile />} />
          <Route path="/settings" element={<Settings />} />
        </Routes>
      </Suspense>
    </BrowserRouter>
  );
}

How it works:

  1. No page component is bundled in the main JavaScript file, which is sent to the client on initial render.

  2. When a user navigates to a route, the corresponding chunk is fetched on demand.

  3. Until the promise resolves, the <Suspense> fallback (i.e., <LoadingSpinner />) is rendered.

  4. Once the promise resolves, the <LoadingSpinner /> component is removed from the DOM, and the particular component is rendered.

Important: Using React.lazy() without wrapping it in <Suspense> will throw an error.

2. Data Fetching with Third-Party Libraries

Libraries like React Query, Relay, and SWR provide built-in Suspense support. For this article, we will see how you can use Suspense along with React Query.

Using React Query with Suspense:

import React, { Suspense } from 'react';
import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query';
import ErrorBoundary from './ErrorBoundary';

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      retry: 1,
      suspense: true, // Enable suspense mode for all queries
    },
  },
});

// User profile component that fetches data
function UserProfile({ userId }) {
  const { data } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetch(`/api/users/${userId}`).then(res => res.json()),
  });

  return (
    <div className="profile-card">
      <h2>{data.name}</h2>
      <p>{data.email}</p>
      <p>Member since: {new Date(data.createdAt).toLocaleDateString()}</p>
    </div>
  );
}

// App component with proper error and loading boundaries
function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <div className="app">
        <h1>User Profile</h1>
        
        <ErrorBoundary fallback={<p>Failed to load user data</p>}>
          <Suspense fallback={<p>Loading user profile...</p>}>
            <UserProfile userId="123" />
          </Suspense>
        </ErrorBoundary>
      </div>
    </QueryClientProvider>
  );
}

This approach makes data fetching more declarative. You focus on what data you need rather than managing loading states manually.

3. Creating Your Own Suspense-Compatible Data Fetcher

If you're not using React Query or Relay, you can write your own custom wrapper that mimics how React's cache API works internally.

In your utils folder, create a file called createResource.ts:

function wrapPromise<T>(promise: Promise<T>) {
  let status: 'pending' | 'success' | 'error' = 'pending';
  let result: T;
  let error: any;
  
  const suspender = promise.then(
    (data) => {
      status = 'success';
      result = data;
    },
    (err) => {
      status = 'error';
      error = err;
    }
  );

  return {
    read() {
      if (status === 'pending') throw suspender;
      if (status === 'error') throw error;
      return result;
    }
  };
}

export function createResource<T>(fetchFn: () => Promise<T>) {
  const promise = fetchFn();
  return wrapPromise(promise);
}

Now you can use it in your components:

import React, { Suspense } from 'react';
import { createResource } from './utils/createResource';
import ErrorBoundary from './components/ErrorBoundary';

// Create a resource
const fetchUserData = (userId) => {
  return fetch(`/api/users/${userId}`).then(res => {
    if (!res.ok) throw new Error('Failed to fetch user');
    return res.json();
  });
};

// Create resources outside of the component
const userResource = createResource(() => fetchUserData('123'));
const postsResource = createResource(() => 
  fetch('/api/posts?userId=123').then(res => res.json())
);

// Component that uses both resources
function UserProfile() {
  const user = userResource.read();
  const posts = postsResource.read();
  
  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
      
      <h3>Recent Posts</h3>
      <ul>
        {posts.map(post => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </div>
  );
}

function App() {
  return (
    <ErrorBoundary fallback={<p>Something went wrong</p>}>
      <Suspense fallback={<p>Loading profile data...</p>}>
        <UserProfile />
      </Suspense>
    </ErrorBoundary>
  );
}

This pattern allows you to handle multiple async dependencies in a single component without callback hell or complex state management.

Cascading Suspenses

If your component contains multiple subcomponents that perform async operations themselves, you can nest multiple Suspense boundaries inside the same component. Each component manages its own loading state. If a deeply nested component suspends, React doesn't directly jump to the topmost fallback. Rather, it shows the closest relevant fallback.

This lets you gain more granular control over UX, allowing you to display fallback UIs to specific sections instead of the whole page. This improves UX as users become more aware of what part of the UI has updated.

Let's see a simple example of cascading suspense where it demonstrates how nested Suspense works:

export default function CascadingDemo() {
	return (
	 <ErrorBoundary key={`cascade-user-${reloadKey}`}>
	    <Suspense fallback={<LoadingSpinner message="Loading user profile..." />}>
	      <UserProfile />
	      <ErrorBoundary key={`cascade-projects-${reloadKey}`}>
	        <Suspense fallback={<LoadingSpinner message="Loading projects..." />}>
	          <UserProjects />
	          <ErrorBoundary key={`cascade-details-${reloadKey}`}>
	            <Suspense fallback={<LoadingSpinner message="Loading project details..." />}>
	              <ProjectDetails />
	            </Suspense>
	          </ErrorBoundary>
	        </Suspense>
	      </ErrorBoundary>
	    </Suspense>
	  </ErrorBoundary>
	)
}

Here, three different spinners are shown at three different time while different parts of the UI is being rendered.

Suspense with TypeScript

When working with TypeScript, properly typing Suspense-related code is important:

// TypeScript definitions for our resource creator
interface Resource<T> {
  read(): T;
}

function wrapPromise<T>(promise: Promise<T>): Resource<T> {
  let status: 'pending' | 'success' | 'error' = 'pending';
  let result: T;
  let error: any;
  
  const suspender = promise.then(
    (data: T) => {
      status = 'success';
      result = data;
    },
    (err: any) => {
      status = 'error';
      error = err;
    }
  );

  return {
    read() {
      if (status === 'pending') throw suspender;
      if (status === 'error') throw error;
      return result;
    }
  };
}

export function createResource<T>(fetchFn: () => Promise<T>): Resource<T> {
  const promise = fetchFn();
  return wrapPromise(promise);
}

// Using it with interfaces
interface User {
  id: string;
  name: string;
  email: string;
  createdAt: string;
}

const userResource: Resource<User> = createResource(() => 
  fetch('/api/users/123').then(res => res.json())
);

Suspense vs. Traditional Approaches

To highlight the benefits of Suspense, let's compare it to traditional loading state management:

Traditional approach:

Let's see how you'd manage loading and error states traditionally without React suspense

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  
  useEffect(() => {
    async function fetchUser() {
      try {
        setLoading(true);
        const response = await fetch(`/api/users/${userId}`);
        if (!response.ok) throw new Error('Failed to fetch user');
        const data = await response.json();
        setUser(data);
        setError(null);
      } catch (err) {
        setError(err.message);
      } finally {
        setLoading(false);
      }
    }
    
    fetchUser();
  }, [userId]);
  
  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;
  
  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
    </div>
  );
}

Three different local states, a useEffect, and different return codes for three different scenarios. Pretty chaotic if you ask me.

Suspense approach:

Now, let's see how React Suspense, along with React Query, simplifies managing these states.

function UserProfile({ userId }) {
  const { data: user } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetch(`/api/users/${userId}`).then(res => res.json()),
  });
  
  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
    </div>
  );
}

// Usage
<ErrorBoundary fallback={<div>Error loading user</div>}>
  <Suspense fallback={<div>Loading...</div>}>
    <UserProfile userId="123" />
  </Suspense>
</ErrorBoundary>

Key differences:

  1. Separation of concerns: Suspense separates loading/error UI from data rendering logic

  2. Reduced boilerplate: No need for loading/error state management in every component

  3. Composability: You can nest Suspense boundaries to create complex loading sequences

  4. Concurrent rendering: Suspense works with React 18's concurrent rendering features

Performance Considerations

When using Suspense, keep these performance considerations in mind:

  1. Suspense boundary placement: Place boundaries strategically to avoid unnecessary UI flashes

  2. Bundle size: Monitor your lazy-loaded chunk sizes to ensure they're reasonably sized

  3. Waterfall requests: Be aware of potential request waterfalls when using nested Suspense components

  4. Caching: Use proper caching strategies with React Query or your custom resource

  5. Suspense with Server Components: Suspense works differently with Server Components, which can generate HTML on the server before sending to the client

Suspense and React Server Components

React Server Components (RSC) and Suspense are closely related. When using RSC:


async function UserProfile({ userId }) {
  // This suspends the component on the server
  const user = await fetch(`/api/users/${userId}`).then(res => res.json());
  
  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
    </div>
  );
}

// In a Client Component
function UserProfilePage({ userId }) {
  return (
    <Suspense fallback={<p>Loading user...</p>}>
      <UserProfile userId={userId} />
    </Suspense>
  );
}

Server Components can suspend during the server rendering phase, and the client will see the fallback until the async operation completes.

Interview Questions

If you are preparing for interviews, here are some questions that would help you prepare better for the interviews:

  1. What are the use cases for React Suspense?

  2. In which scenarios does a component "suspend"? How does a component wrapped in Suspense behave when it is suspended?

  3. How would you design an app that uses Suspense to progressively stream content from the server?

  4. What are cascading Suspense boundaries, and why are they important for UX and performance in large-scale apps?

  5. For now, which data fetching libraries support Suspense? Can you create your own custom data fetching function that supports Suspense? How?

Wrapping Up

React Suspense is a powerful feature that simplifies working with async operations in React applications. By providing a declarative way to handle loading states, it helps you create smoother user experiences with less boilerplate code.

When you combine it with lazy loading, error boundaries, and modern data fetching libraries gives you a comprehensive toolkit for building performant React applications.

I hope you learned something valuable from this article. React Suspense is quite tricky and overwhelming. So it's absolutely fine if you didn't fully grasp it at first read. Take a walk, come back to this later, and read it with a fresh mind while trying out the code examples on your own. I am sure you'll get a better idea of it.

Here are some other articles diving deep into React fundamentals:

  1. What is useCallback Hook in React?

  2. What is forwardRef in React?

  3. What is useLayoutEffect in React?

  4. useRef Hook in React - Use Cases, Code Examples, Interview Prep

  5. What Are Portals in React?

If you want an article about a specific topic on React, frontend, or web development, feel free to let me know! I am most active on Twitter and Peerlist, if you want to say hi!

Until then, enjoy the fallback UIs! 😉

Create Profile

or continue with email

By clicking "Create Profile“ you agree to our Code of Conduct, Terms of Service and Privacy Policy.