This article is published in English.
Parallel Fetching in React: Suspense with Promise.all and allSettled
Learn how to remove request waterfalls with Promise.all, keep optional data from breaking pages with Promise.allSettled, and show loading states with Suspense.
A page that shows spinner after spinner usually has one of two problems. Either its requests run one after another when they could run together, or one optional request, such as a recommendations widget, fails and takes the whole view down with it. Three tools address both: Promise.all and Promise.allSettled for starting work concurrently with different failure policies, and React's Suspense for showing a fallback while data is pending. This guide walks through each with a small demo that loads two lists from a public API.
Where waterfalls come from
Awaiting calls one at a time serializes them. The second request does not even start until the first has finished, so total latency is the sum of both:
const categories = await fetchCategories();
// fetchPosts will only start running after fetchCategories finishes executing.
const posts = await fetchPosts();
When the requests do not depend on each other, start both first and await them together. Total time then drops to roughly the slowest request instead of the sum. For a broader comparison of these combinators, see choosing between Promise.all, Promise.race and sequential awaits.
Suspense: declarative loading states
Suspense wraps part of the tree and renders a fallback, such as a skeleton or a "Loading data..." message, while anything inside it is waiting. The rest of the page stays visible, and you no longer need a hand-written loading flag for that section.
In the demo, an async function waits an artificial three seconds to make the loading state visible, then fires the spells and books requests concurrently and combines them with Promise.all. The resulting promise is passed to Section1, which unwraps it with React's use hook. Until the promise resolves, use suspends the component and the fallback shows.
'use client';
import React, { Suspense, use } from 'react';
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
export default function Home() {
const fetchDataParalel = async () => {
await delay(3000); // Simulate a 3-second loading delay
// Trigger both APIs in PARALLEL (simultaneously)
const getSpellsPromise = fetch(
'https://potterapi-fedeperin.vercel.app/en/spells?max=3&page=1'
).then((res) => res.json());
const getBooksPromise = fetch(
'https://potterapi-fedeperin.vercel.app/en/books?max=3&page=1'
).then((res) => res.json());
// Wait for both to finish together
const [spellsData, booksData] = await Promise.all([
getSpellsPromise,
getBooksPromise,
]);
return {
spells: spellsData?.map((item: any) => item.spell) || [],
books: booksData?.map((item: any) => item.title) || [],
};
};
return (
<main className="p-4">
<p>
<strong>Demo Suspense + Promise.all</strong>
</p>
<br />
<Suspense fallback={<p>Loading data...</p>}>
<Section1 dataPromise={fetchDataParalel()} />
</Suspense>
</main>
);
}
function Section1({
dataPromise,
}: {
dataPromise: Promise<{ spells: string[]; books: string[] }>;
}) {
const data = use(dataPromise);
return (
<div>
<p>
<strong>Spells: </strong>{' '}
{data.spells.length ? data.spells.join(', ') : '-'}
</p>
<p>
<strong>Books: </strong>{' '}
{data.books.length ? data.books.join(', ') : '-'}
</p>
</div>
);
}
One caution: this component calls fetchDataParalel() during render, so a new promise is created every time Home renders. React expects promises passed to use to be stable, and in a client component an uncached promise can trigger warnings or repeated fetching. In Next.js, the more idiomatic version creates the promise in a Server Component (or caches it) and passes it down, or simply makes the section an async Server Component wrapped in Suspense.
Promise.all: everything or nothing
Promise.all suits data that is all mandatory. It resolves with an array of results in the original order once every input fulfils, and it rejects as soon as any input rejects. The second demo uses the same parallel fetch, this time from useEffect with an explicit loading state instead of Suspense:
'use client';
import React, { Suspense, use } from 'react';
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
export default function HomePromiseAll() {
const [loading, setLoading] = React.useState(false);
const [spells, setSpells] = React.useState([]);
const [books, setBooks] = React.useState([]);
const fetchDataParalel = async () => {
setLoading(true);
await delay(3000);
const getSpellsPromise = fetch(
'https://potterapi-fedeperin.vercel.app/en/spells?max=3&page=1'
).then((res) => res.json());
const getBooksPromise = fetch(
'https://potterapi-fedeperin.vercel.app/en/books?max=3&page=1'
).then((res) => res.json());
const [spellsData, booksData] = await Promise.all([
getSpellsPromise,
getBooksPromise,
]);
setSpells(spellsData?.map((item: any) => item.spell) || []);
setBooks(booksData?.map((item: any) => item.title) || []);
setLoading(false);
};
React.useEffect(() => {
fetchDataParalel();
}, []);
return (
<main>
<h1>Demo Promise.all</h1>
<br />
{loading ? (
<p>Loading data...</p>
) : (
<Section1 spells={spells} books={books} />
)}
</main>
);
}
function Section1({ spells, books }: { spells: string[]; books: string[] }) {
return (
<div>
<p>
<strong>Spells: </strong> {spells.length ? spells.join(', ') : '-'}
</p>
<p>
<strong>Books: </strong> {books.length ? books.join(', ') : '-'}
</p>
</div>
);
}
Notice that if either request rejects, execution never reaches setLoading(false), and the page shows "Loading data..." forever. Wrap the body in try/finally, and render an error state in a catch, so the rejection is surfaced rather than swallowed. Promise.all also does not cancel the other request when one fails; it simply stops waiting for it.
Promise.allSettled: tolerate partial failure
Promise.allSettled never rejects. It waits for every input and returns an object per input with a status of either fulfilled (with a value) or rejected (with a reason). You then decide per result what to show:
'use client';
import React from 'react';
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
export default function HomePromiseAllSettled() {
const [loading, setLoading] = React.useState(false);
const [spells, setSpells] = React.useState<string[]>([]);
const [books, setBooks] = React.useState<string[]>([]);
const fetchDataParalel = async () => {
setLoading(true);
await delay(3000);
const getSpellsPromise = fetch(
'https://potterapi-fedeperin.vercel.app/en/spells?max=3&page=1'
).then((res) => res.json());
const getBooksPromise = fetch(
'https://potterapi-fedeperin.vercel.app/en/books?max=3&page=1'
).then((res) => res.json());
const [spellsResult, booksResult] = await Promise.allSettled([
getSpellsPromise,
getBooksPromise,
]);
// check status 'fulfilled' and get from `.value`
const finalSpells =
spellsResult.status === 'fulfilled'
? spellsResult.value.map((item: any) => item.spell)
: [];
const finalBooks =
booksResult.status === 'fulfilled'
? booksResult.value.map((item: any) => item.title)
: [];
setSpells(finalSpells);
setBooks(finalBooks);
setLoading(false);
};
React.useEffect(() => {
fetchDataParalel();
}, []);
return (
<main>
<h1>Demo Promise.allSettled</h1>
<br />
{loading ? (
<p>Loading data...</p>
) : (
<Section1 spells={spells} books={books} />
)}
</main>
);
}
function Section1({ spells, books }: { spells: string[]; books: string[] }) {
return (
<div>
<p>
<strong>Spells: </strong> {spells.length ? spells.join(', ') : '-'}
</p>
<p>
<strong>Books: </strong> {books.length ? books.join(', ') : '-'}
</p>
</div>
);
}
Here a failed spells request yields an empty list while the books still render. This is the right policy for secondary content, but be aware of what counts as failure. fetch rejects only on network errors, and the .json() call rejects only on unparseable bodies. An HTTP 500 that returns JSON is still fulfilled, so check res.ok inside each chain if you want server errors treated as rejections.
Picking the right combination
- Use
Promise.allwhen the view is meaningless without every piece of data, and pair it with an error boundary or explicit error state. - Use
Promise.allSettledwhen some sections are optional and a partial page is better than none. - Wrap each independently loading section in its own
Suspenseboundary so slow data holds back only its own region. - Keep promises stable: create them on the server or cache them, not on every client render.
Wrapping up
Waterfalls and single points of failure are architectural choices rather than inevitabilities. Starting independent work together, choosing a failure policy deliberately, and letting Suspense manage loading regions gives users a page that appears quickly and degrades gracefully when one dependency misbehaves.