Type something to search...
Suspense for Data Fetching: How It Works Under the Hood

Suspense for Data Fetching: How It Works Under the Hood

Suspense looks like magic the first time you use it for data. A component reads data as if it were already there, with no isLoading flag and no useEffect, and somewhere above it a <Suspense fallback> shows a spinner until the data arrives. Then you try to build your own data fetching with it and hit an infinite loop, a fallback that never goes away, or a warning about an "uncached promise".

Those problems all make sense once you know what Suspense actually does. The mechanism is surprisingly small: a component signals "I'm not ready" by handing React a promise, React shows the nearest fallback, and when the promise settles, React tries rendering again. Everything else, including the rules about caching, follows from that.

In this post you'll see how suspending works from the inside: the old "throw a promise" contract, how React 19's use hook tracks promise state, why promises must be cached, how React decides which fallback to show, how transitions change that decision, and how libraries like TanStack Query and React Router plug into it. You'll also build a tiny Suspense-compatible cache to make each step concrete.

The Core Idea: Rendering Can Be Interrupted

Normally a component's render either returns JSX or throws an error. Suspense adds a third outcome: the component suspends, meaning "I can't produce output yet, try me again later."

React handles that outcome a lot like an error:

  1. Rendering stops at the component that suspended.
  2. React walks up the tree to the nearest Suspense boundary.
  3. That boundary renders its fallback instead of its children.
  4. React subscribes to the promise that caused the suspension.
  5. When the promise settles, React re-renders the boundary's children from scratch.

The important part is step 5. React doesn't pause the function and resume it later, because JavaScript functions can't be paused. It throws away the partial work and calls your component again. That's why the component must be able to read the data synchronously on the second attempt, and why the data has to live somewhere outside the component.

The Original Contract: Throwing a Promise

Before React 19, libraries implemented Suspense by literally throwing a promise during render. This was never a public, documented API, but it's the clearest way to see the mechanism. Here's a minimal resource built that way:

// An illustration of the pre-React-19 pattern, not something to ship.
type Resource<T> = { read(): T };

export function createResource<T>(promise: Promise<T>): Resource<T> {
  let status: "pending" | "success" | "error" = "pending";
  let result: T;
  let error: unknown;

  const suspender = promise.then(
    (value) => {
      status = "success";
      result = value;
    },
    (reason) => {
      status = "error";
      error = reason;
    },
  );

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

read() has three branches that line up exactly with the three render outcomes. Pending throws a promise, which suspends. Error throws the error, which goes to an error boundary. Success returns the data synchronously.

React's internals check whether a thrown value is a thenable. If it is, React treats it as a suspension instead of an error, attaches a callback to it, and retries rendering when it settles. That's the entire trick.

How the use Hook Works in React 19

React 19 made Suspense for data official with the use hook. You pass it a promise and it returns the resolved value:

import { use, Suspense } from "react";

type User = { id: number; name: string };

function UserName({ userPromise }: { userPromise: Promise<User> }) {
  const user = use(userPromise);
  return <h2>{user.name}</h2>;
}

export function Profile({ userPromise }: { userPromise: Promise<User> }) {
  return (
    <Suspense fallback={<p>Loading user...</p>}>
      <UserName userPromise={userPromise} />
    </Suspense>
  );
}

Inside, use does roughly what read() did, but it stores state on the promise itself instead of in a wrapper:

  1. If the promise has status === "fulfilled", return promise.value.
  2. If it has status === "rejected", throw promise.reason.
  3. Otherwise, set status = "pending" if it isn't tracked yet, attach then handlers that will write status and value or reason onto the promise, and suspend.

React suspends using an internal exception rather than throwing your promise directly, which is why you should never catch it in a try/catch around use. The observable behavior is the same: the nearest boundary shows its fallback, and when the promise settles, the component renders again. On that second render, the promise already has status === "fulfilled", so use returns the value immediately.

These status fields are an implementation detail, but they explain an important behavior. If you pass the same promise object to use on the retry, React finds the value on it and continues. If you pass a new promise, React has never seen it, so it suspends again. That leads straight to the most important rule.

Unlike other hooks, use can be called inside conditions and loops. There's more on that in the React use hook for promises and context.

Why Promises Must Be Cached

Here's the bug almost everyone writes first:

function UserName({ id }: { id: number }) {
  // Creates a new promise on every render
  const user = use(fetch(`/api/users/${id}`).then((r) => r.json()));
  return <h2>{user.name}</h2>;
}

Walk through it with the mechanism in mind:

  1. First render: a new promise A is created, use suspends on it.
  2. A resolves. React retries the render.
  3. Second render: a new promise B is created, which is pending. use suspends again.
  4. B resolves, React retries, promise C is created, and so on forever.

The component never sees data, and you send a request on every attempt. React 19 detects this and logs a warning that a component was suspended by an uncached promise.

The fix is to create the promise outside render, or to get it from a cache that returns the same promise object for the same input. Here's a small cache:

// src/lib/suspense-cache.ts
const cache = new Map<string, Promise<unknown>>();

export function fetchJson<T>(url: string): Promise<T> {
  let promise = cache.get(url) as Promise<T> | undefined;
  if (!promise) {
    promise = fetch(url).then((res) => {
      if (!res.ok) throw new Error(`Request failed: ${res.status}`);
      return res.json() as Promise<T>;
    });
    cache.set(url, promise);
  }
  return promise;
}

export function invalidate(url: string) {
  cache.delete(url);
}
import { use } from "react";
import { fetchJson } from "./lib/suspense-cache";

type User = { id: number; name: string };

export function UserName({ id }: { id: number }) {
  const user = use(fetchJson<User>(`/api/users/${id}`));
  return <h2>{user.name}</h2>;
}

Now the first render creates the promise and stores it. The retry gets the same object from the map, use finds status === "fulfilled", and the component renders. This is the core of every Suspense data library: a cache keyed by the request, returning stable promises.

A real cache needs more: eviction, refetching, and error handling that lets failed requests retry. That's exactly what libraries provide, which is why the React team recommends using a Suspense-enabled library rather than hand-rolling one.

Creating Promises Ahead of Render

The other way to get a stable promise is to start the request before rendering, in an event handler, a router loader, or a Server Component, and pass the promise down:

import { Suspense, useState, useTransition } from "react";
import { UserDetails } from "./user-details";

type User = { id: number; name: string; email: string };

function loadUser(id: number): Promise<User> {
  return fetch(`/api/users/${id}`).then((r) => r.json());
}

export function UserPicker() {
  const [userPromise, setUserPromise] = useState<Promise<User> | null>(null);
  const [isPending, startTransition] = useTransition();

  function select(id: number) {
    startTransition(() => {
      setUserPromise(loadUser(id));
    });
  }

  return (
    <div>
      <button onClick={() => select(1)}>User 1</button>
      <button onClick={() => select(2)}>User 2</button>
      {isPending && <span> Loading...</span>}
      {userPromise && (
        <Suspense fallback={<p>Loading details...</p>}>
          <UserDetails userPromise={userPromise} />
        </Suspense>
      )}
    </div>
  );
}

The promise is created once, in the click handler, and stored in state. Every render reads the same object. This pattern is called render-as-you-fetch: the request starts as early as possible, and rendering waits on it, instead of the component starting its own fetch after mounting.

Which Fallback Shows, and When

When a component suspends, React looks for the closest Suspense boundary above it. Everything inside that boundary is replaced by the fallback, and everything outside stays visible.

<Suspense fallback={<PageSkeleton />}>
  <Header />
  <Suspense fallback={<FeedSkeleton />}>
    <Feed />
  </Suspense>
  <Suspense fallback={<SidebarSkeleton />}>
    <Sidebar />
  </Suspense>
</Suspense>

If Feed suspends, only FeedSkeleton appears. If Header suspends, the outer PageSkeleton replaces everything. Each boundary is an independent loading region, and sibling boundaries reveal on their own as their data arrives.

A few runtime details are worth knowing:

  • A boundary waits for all its children. If two components inside one boundary suspend, the fallback stays until both are ready. Group content that should appear together.
  • React throttles reveals. When nested boundaries resolve in quick succession, React batches the reveals over a short window so the page doesn't pop in one piece at a time.
  • Siblings still get rendered. In React 19, when a component suspends, React commits the fallback quickly and then pre-renders the suspended siblings in the background, so their own data requests start too instead of waiting in a waterfall.
  • State inside a boundary is lost on first suspend. If a boundary has never shown its children, there's nothing to keep. If it has, see the next section.

Transitions Change the Rules

Imagine a page already showing User 1, and the user clicks User 2. The new UserDetails suspends. Should React swap the current details for a skeleton?

Usually not. Replacing visible content with a spinner feels like going backward. So React treats updates differently depending on how they were triggered:

  • Urgent update (a normal setState): if something suspends inside an already-visible boundary, React shows the fallback.
  • Transition (inside startTransition or useTransition): React keeps showing the old UI and renders the new one in the background. It only commits once the new content is ready. isPending is true meanwhile, so you can dim the old content.

That's why the UserPicker example wraps setUserPromise in startTransition. Without it, every click would flash the fallback. With it, the old user's details stay visible until the new ones are ready.

Transitions only hold back fallbacks for boundaries that are already showing content. A brand new boundary that appears for the first time still shows its fallback, since there's no old UI to keep. React Router wraps navigations in transitions for this exact reason. You can read more in useTransition and useDeferredValue for smoother UIs.

Showing Stale Content With useDeferredValue

For search-as-you-type UIs, useDeferredValue gives the same effect without wrapping the setter:

import { Suspense, useDeferredValue, useState } from "react";
import { SearchResults } from "./search-results";

export function Search() {
  const [query, setQuery] = useState("");
  const deferredQuery = useDeferredValue(query);
  const isStale = query !== deferredQuery;

  return (
    <>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <Suspense fallback={<p>Searching...</p>}>
        <div style={{ opacity: isStale ? 0.5 : 1 }}>
          <SearchResults query={deferredQuery} />
        </div>
      </Suspense>
    </>
  );
}

The input updates immediately. SearchResults renders with the deferred query in the background, and if that render suspends, React keeps showing the previous results instead of the fallback.

Errors and Retries

A rejected promise becomes a thrown error on retry, so it goes to the nearest error boundary, not the Suspense boundary. Always pair them:

import { ErrorBoundary } from "react-error-boundary";

<ErrorBoundary fallback={<p>Couldn't load the feed.</p>}>
  <Suspense fallback={<FeedSkeleton />}>
    <Feed />
  </Suspense>
</ErrorBoundary>;

To retry, you need a new promise, because the old one is permanently rejected. With the hand-written cache above, that means calling invalidate(url) before resetting the error boundary. Libraries handle this for you, which is another reason to use one.

How Libraries Plug In

Every Suspense-enabled data library implements the same pieces: a cache of stable promises, invalidation, and a hook that suspends while data is pending.

TanStack Query provides useSuspenseQuery. The returned data is always defined, because the component suspends until it is:

import { useSuspenseQuery } from "@tanstack/react-query";

type Todo = { id: number; title: string };

export function TodoList() {
  const { data: todos } = useSuspenseQuery({
    queryKey: ["todos"],
    queryFn: async (): Promise<Todo[]> => {
      const res = await fetch("/api/todos");
      if (!res.ok) throw new Error("Failed to load todos");
      return res.json();
    },
  });

  return (
    <ul>
      {todos.map((t) => (
        <li key={t.id}>{t.title}</li>
      ))}
    </ul>
  );
}

The query cache keyed by ["todos"] plays the role of the Map from earlier. See managing server state with TanStack Query for the full setup.

React Router lets a loader return an object containing promises without awaiting them. You render them with use or the Await component inside Suspense, so the page shell renders right away and slow data streams in.

Server Components can pass a promise created on the server down to a Client Component, which reads it with use. The framework streams the fallback first and the content later.

Common Mistakes With Suspense for Data

  • Creating the promise during render. It causes infinite suspension. Cache it or create it outside render.
  • Wrapping use in try/catch. It intercepts React's internal suspension signal. Handle errors with error boundaries.
  • One boundary for the whole page. Any slow request blanks everything. Add boundaries around independent regions.
  • Fetching in a child after a parent suspends. Sequential suspensions create waterfalls. Start requests early, in loaders or handlers, or fetch in parallel.
  • Forgetting the error boundary. A rejected promise without one crashes up to the root.
  • Expecting transitions to hide first-time fallbacks. They only keep already-visible content on screen.

Frequently Asked Questions (FAQ) About Suspense for Data Fetching

No. Suspense only coordinates loading states. Something else must start the request and provide a stable promise, such as a data library, a router loader, a Server Component, or your own cache. Suspense decides what to show while that promise is pending.

It still works in many cases because older libraries rely on it, but it was never a public API. In React 19 use the use hook or a library built on it. Don't throw promises in new code.

Almost always because a new promise is created on every render, so each retry suspends on a fresh pending promise. Make sure the same promise object is returned for the same request, either from a cache or from state set outside render.

A component that suspends before ever committing has no state to keep, so it starts fresh on retry. Effects don't run until the content commits. Content that was already visible keeps its state when hidden behind a fallback, and its layout effects are cleaned up and re-run when it reappears.

Fetch-on-render starts requests inside components, usually in effects, so children only start fetching after parents finish. Render-as-you-fetch starts requests before rendering, in loaders or event handlers, and components read the already-started promises, which avoids waterfalls.

Yes. In a client-side React 19 app you can use use with a promise cache, TanStack Query's useSuspenseQuery, or React Router loaders. Frameworks add server streaming, but the client mechanism is the same.

Conclusion

Under the hood, Suspense is a small protocol. A component that isn't ready suspends with a promise, React shows the nearest boundary's fallback, and when the promise settles, React renders the component again from the start. React 19's use hook tracks the promise's status on the promise itself, which is why the same promise must come back on the retry and why creating promises during render loops forever.

With that model in mind, the rest falls into place: cache promises or create them ahead of render, place boundaries around regions that should load together, use transitions to keep visible content on screen, and pair every Suspense boundary with an error boundary. For production apps, lean on a library like TanStack Query or React Router loaders, and use this knowledge to debug them when a fallback misbehaves.

Tags :
Share :

Related Posts

A Practical Guide to useEffect and Its Dependency Array

A Practical Guide to useEffect and Its Dependency Array

useEffect is the hook people get wrong most often, and the dependency array is usually where it goes wrong. Leave a value out and your effect works

Continue Reading
Accessibility Best Practices for React Developers

Accessibility Best Practices for React Developers

React makes it easy to build interfaces out of anything. A div with an onClick looks and behaves like a button for a mouse user, so it ships. The

Continue Reading
Animations in React with Motion (Framer Motion)

Animations in React with Motion (Framer Motion)

CSS transitions get you far, until you need to animate something leaving the page. React removes the element from the DOM immediately, so there's not

Continue Reading