Ever opened an app and just stared at a spinning loader for way too long? That usually happens because of one small mistake: the app is fetching data one thing at a time, instead of all at once.
In this post, we'll fix that. You'll learn three tools that work together to make your Next.js app load faster and handle errors gracefully:
- Suspense — shows a loading UI without freezing the whole page
- Promise.all — fetches multiple things together, when all of them are required
- Promise.allSettled — fetches multiple things together, but doesn't break if some of them fail
The Problem: Fetching Data One By One (a "Waterfall")
Look at this code:
const user = await fetchUser();
// fetchPosts only starts AFTER fetchUser finishes
const posts = await fetchPosts();Here, fetchPosts() waits for fetchUser() to fully complete before it even starts. If each call takes 1 second, the total wait is 2 seconds — even though the two requests have nothing to do with each other.
This "wait in a line" pattern is called a request waterfall, and it's one of the most common reasons apps feel slow.
The Fix: Run Requests at the Same Time
Instead of waiting for one request before starting the next, we can kick off both requests together and then wait for both to finish. If each takes 1 second, the total wait becomes roughly 1 second, not 2.
Let's look at the three tools that make this clean and safe to do.
1. Suspense — Loading UI Without the Headache
Suspense is a built-in React component. You wrap it around a part of your page that needs to fetch data. While the data is loading, Suspense shows a fallback (like "Loading...") instead of leaving that whole section blank or freezing the page.
Here's a simple example using two public APIs (users and to-dos):
'use client';
import { Suspense, use } from 'react';
async function getDashboardData() {
// Both requests start at the same time
const usersPromise = fetch('https://jsonplaceholder.typicode.com/users?_limit=3')
.then((res) => res.json());
const todosPromise = fetch('https://jsonplaceholder.typicode.com/todos?_limit=3')
.then((res) => res.json());
// Wait for both together
const [users, todos] = await Promise.all([usersPromise, todosPromise]);
return {
userNames: users.map((u: any) => u.name),
todoTitles: todos.map((t: any) => t.title),
};
}
export default function DashboardPage() {
return (
<main className="p-4">
<h1>Dashboard</h1>
<Suspense fallback={<p>Loading dashboard...</p>}>
<DashboardContent dataPromise={getDashboardData()} />
</Suspense>
</main>
);
}
function DashboardContent({
dataPromise,
}: {
dataPromise: Promise<{ userNames: string[]; todoTitles: string[] }>;
}) {
const data = use(dataPromise); // pauses this component until data is ready
return (
<div>
<p><strong>Users:</strong> {data.userNames.join(', ')}</p>
<p><strong>Todos:</strong> {data.todoTitles.join(', ')}</p>
</div>
);
}What's happening: the use() hook lets a component "pause" until a promise resolves. Suspense catches that pause and shows the fallback UI instead of a blank screen. The rest of your page stays interactive.
2. Promise.all — "I Need All of These, or None"
Use Promise.all when every request is required. If even one request fails, the whole thing is treated as failed — so make sure this is really what you want before using it.
'use client';
import { useState, useEffect } from 'react';
export default function DashboardWithState() {
const [loading, setLoading] = useState(true);
const [userNames, setUserNames] = useState<string[]>([]);
const [todoTitles, setTodoTitles] = useState<string[]>([]);
const loadData = async () => {
setLoading(true);
const usersPromise = fetch('https://jsonplaceholder.typicode.com/users?_limit=3')
.then((res) => res.json());
const todosPromise = fetch('https://jsonplaceholder.typicode.com/todos?_limit=3')
.then((res) => res.json());
// If either request fails, this whole block fails
const [users, todos] = await Promise.all([usersPromise, todosPromise]);
setUserNames(users.map((u: any) => u.name));
setTodoTitles(todos.map((t: any) => t.title));
setLoading(false);
};
useEffect(() => {
loadData();
}, []);
if (loading) return <p>Loading data...</p>;
return (
<div>
<p><strong>Users:</strong> {userNames.join(', ')}</p>
<p><strong>Todos:</strong> {todoTitles.join(', ')}</p>
</div>
);
}When to use it: things like "load the product AND its price" — showing a product without a price makes no sense, so it's fine for the whole load to fail together.
3. Promise.allSettled — "Show Me Whatever Worked"
Sometimes one piece of data is optional — like a "recommended for you" widget. If that one request fails, you don't want to break the entire page. That's where Promise.allSettled comes in.
It waits for every request to finish, and tells you which ones succeeded (fulfilled) and which ones failed (rejected) — without crashing the rest.
'use client';
import { useState, useEffect } from 'react';
export default function DashboardResilient() {
const [loading, setLoading] = useState(true);
const [userNames, setUserNames] = useState<string[]>([]);
const [todoTitles, setTodoTitles] = useState<string[]>([]);
const loadData = async () => {
setLoading(true);
const usersPromise = fetch('https://jsonplaceholder.typicode.com/users?_limit=3')
.then((res) => res.json());
const todosPromise = fetch('https://jsonplaceholder.typicode.com/todos?_limit=3')
.then((res) => res.json());
const [usersResult, todosResult] = await Promise.allSettled([
usersPromise,
todosPromise,
]);
// Only use the data if that specific request succeeded
setUserNames(
usersResult.status === 'fulfilled'
? usersResult.value.map((u: any) => u.name)
: []
);
setTodoTitles(
todosResult.status === 'fulfilled'
? todosResult.value.map((t: any) => t.title)
: []
);
setLoading(false);
};
useEffect(() => {
loadData();
}, []);
if (loading) return <p>Loading data...</p>;
return (
<div>
<p><strong>Users:</strong> {userNames.length ? userNames.join(', ') : 'Unavailable'}</p>
<p><strong>Todos:</strong> {todoTitles.length ? todoTitles.join(', ') : 'Unavailable'}</p>
</div>
);
}When to use it: dashboards, home pages, or anywhere you have multiple independent widgets — one broken API shouldn't take down the rest of the page.
Quick Cheat Sheet
| Tool | Use it when... | If one request fails... |
|---|---|---|
Suspense | You want a loading UI without freezing the page | Shows fallback until data is ready |
Promise.all | All the data is required together | Everything fails together |
Promise.allSettled | Some data is optional / independent | Only that piece is missing, rest still shows |
Wrapping Up
The main idea is simple: don't make your users wait in a line for data that doesn't depend on each other. Fire your requests together, use Suspense to keep the UI responsive while waiting, and choose Promise.all or Promise.allSettled depending on whether all your data is required or some of it is optional.
Once you get comfortable with this pattern, your Next.js apps will feel noticeably faster — and a lot more resilient when one API has a bad day.
Let's Build Your Product the Right Way
I'm Dhanasekaran, a Full Stack Engineer based in India with 7+ years of experience building production-grade web and mobile applications — using Next.js, Node.js, React Native, and Kotlin, all structured with Clean Architecture principles.
Whether you're starting from scratch or need to rescue an existing project, I'd love to discuss your needs.
Or connect with me directly on LinkedIn to talk about your project.
Dhanasekaran is a Full Stack Engineer with 7+ years of experience in web and mobile development. He works with founders and businesses to build scalable, maintainable software products from the ground up.