This article is published in English.
Nine Promise Patterns for Reliable Async JavaScript in Production
Learn practical Promise patterns—parallel requests, timeouts, retries, concurrency limits, and cancellation—for building resilient, production-grade async JavaScript.
You reach for Promises constantly, yet a handful of lesser-known patterns can turn tangled async code into something predictable and easy to reason about.
Most people pick up Promises through an example like this:
fetch("/api/users")
.then((res) => res.json())
.then((users) => console.log(users));
And for simple scripts, that's genuinely all you need.
The trouble starts once you move into production-grade applications.
Suddenly you need several requests to run at the same time instead of one after another.
You need a way to cancel work that's no longer relevant.
You need retries when a request fails.
You need to keep going even when only part of an operation succeeds.
You need to stop the same API call from firing twice by accident.
And sometimes you need to cap how many async operations run simultaneously so your backend isn't hit with everything at once.
This is the point where Promises stop being a beginner topic and start becoming a real design tool.
Below are nine patterns worth having in your toolbox when writing modern JavaScript.
1. Run Independent Requests in Parallel
One of the simplest wins in async code is also one of the easiest to overlook.
Say your page needs three things: user data, notifications, and analytics.
A common first attempt looks like this:
const users = await getUsers();
const notifications = await getNotifications();
const analytics = await getAnalytics();
Each request waits for the previous one.
Each await blocks until the previous call resolves, so the calls stack up sequentially.
Assume each call takes roughly 500ms — that sequential chain could add up to around 1.5 seconds of waiting.
If none of these calls actually depend on each other, there's no reason to wait.
This is exactly what Promise.all() is for:
const [users, notifications, analytics] = await Promise.all([
getUsers(),
getNotifications(),
getAnalytics(),
]);
All three requests now fire at once instead of taking turns.
For dashboards or any screen loading multiple independent data sources, this can noticeably cut load time.
The important catch
Promise.all() fails fast: the moment any one of the Promises rejects, the whole group rejects with it.
That's fine when every request is mandatory, but if some of the data is optional, you'll want a more forgiving approach.
2. Use Promise.allSettled() When Partial Failure Is Okay
Picture an admin dashboard that displays:
- Revenue
- Users
- Notifications
- System health
If the notifications service happens to be down, should the whole screen go blank?
Usually not — losing one panel shouldn't take the rest of the page with it.
Promise.allSettled() solves exactly this problem.
const results = await Promise.allSettled([
getRevenue(),
getUsers(),
getNotifications(),
getSystemHealth(),
]);
results.forEach((result) => {
if (result.status === "fulfilled") {
console.log("Success:", result.value);
} else {
console.error("Failed:", result.reason);
}
});
A single failed call no longer wipes out the successful results next to it.
This pattern earns its keep on dashboards, analytics screens, and monitoring tools, where showing partial data beats showing nothing.
3. Add a Timeout to a Promise
Sooner or later you'll run into this scenario: what happens if a request simply never comes back?
Without a safeguard, your interface can hang indefinitely.
You can build a small, reusable wrapper that enforces a timeout:
function withTimeout(promise, ms) {
const timeout = new Promise((_, reject) => {
setTimeout(() => {
reject(new Error("Operation timed out"));
}, ms);
});
return Promise.race([promise, timeout]);
}
Then use it wherever you make a request:
try {
const data = await withTimeout(fetch("/api/data"), 5000);
console.log(data);
} catch (error) {
console.error(error);
}
If the call doesn't finish inside five seconds, the wrapped Promise rejects on its own.
That's a far better experience than leaving users staring at a spinner that never resolves.
4. Retry Failed Operations
Networks drop connections.
Servers hiccup.
Third-party APIs occasionally misbehave.
None of that necessarily means the user should immediately see an error message.
For failures that are likely temporary, a small retry helper goes a long way.
async function retry(fn, attempts = 3) {
let lastError;
for (let i = 0; i < attempts; i++) {
try {
return await fn();
} catch (error) {
lastError = error;
}
}
throw lastError;
}
You'd call it like this:
const data = await retry(
() => fetch("/api/data"),
3
);
The operation now gets a few extra chances before it's treated as a real failure.
But don't blindly retry everything.
A 500 status often signals a transient server issue.
A 401 Unauthorized, on the other hand, won't be fixed by firing off the same request three more times.
Solid retry logic distinguishes between failures worth retrying and ones that aren't.
5. Add Delays Between Retries
Firing a retry the instant a request fails isn't always the smart move.
Consider a server that's already struggling under load.
If thousands of clients all retry at the same instant, you're piling more pressure onto a system that's already stressed.
A basic delay function fixes this:
function delay(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
You can slot it directly into your retry loop:
async function retry(fn, attempts = 3) {
let lastError;
for (let i = 0; i < attempts; i++) {
try {
return await fn();
} catch (error) {
lastError = error;
if (i < attempts - 1) {
await delay(1000);
}}}
throw lastError;
}
Production systems frequently go a step further and use exponential backoff, spacing retries out like this:
1 second
2 seconds
4 seconds
8 seconds
Spreading retries out this way gives the struggling server room to recover instead of triggering a retry storm.
6. Control Concurrency
There's an issue that Promise.all() can quietly introduce.
Say you need to process 1,000 API requests.
The naive approach looks harmless enough:
await Promise.all(
items.map((item) => processItem(item))
);
But now you’re potentially starting 1,000 operations at once.
But this means you might be launching all 1,000 operations at the exact same moment.
That's rarely what you actually want.
Often you'd rather cap how many operations run in parallel.
Picture something like this:
1000 tasks
↓
5 at a time
↓
5 at a time
↓
5 at a time
Building a basic concurrency limiter isn't hard:
async function runWithLimit(items, limit, fn) {
const results = [];
let index = 0;
async function worker() {
while (index < items.length) {
const currentIndex = index++;
results[currentIndex] = await fn(items[currentIndex]);
}
}
const workers = Array.from(
{ length: limit },
() => worker()
);
await Promise.all(workers);
return results;
}
You'd use it like this:
const results = await runWithLimit(
items,
5,
(item) => processItem(item)
);
Now you decide exactly how many tasks execute at once instead of letting the runtime decide for you.
This technique pays off enormously when you're dealing with large datasets, external APIs, file processing, or background job queues.
7. Prevent Duplicate Requests
Here's a problem that shows up more often than you'd expect.
A user loads a dashboard page.
Three separate components each need the same user profile data.
Rather than firing off three separate calls:
Component A → /api/user
Component B → /api/user
Component C → /api/user
you can have them share a single Promise instead.
const pendingRequests = new Map();
function getUser(id) {
if (pendingRequests.has(id)) {
return pendingRequests.get(id);
}
const request = fetch(`/api/users/${id}`)
.then((res) => res.json())
.finally(() => {
pendingRequests.delete(id);
});
pendingRequests.set(id, request);
return request;
}
With this setup, if three components ask for the same user at roughly the same moment, they all attach to one in-flight request instead of triggering three.
In other words:
3 requests → 1 request
This technique is often referred to as request deduplication.
For larger codebases, tools like TanStack Query already implement caching and deduplication logic like this for you.
8. Use Promise.any() When You Only Need One Successful Result
Sometimes the same piece of data is available from several different places.
For instance:
API Server A
API Server B
API Server C
If your app just needs a single successful response from any of them, Promise.any() fits the job.
const result = await Promise.any([
fetchFromServerA(),
fetchFromServerB(),
fetchFromServerC(),
]);
Whichever Promise fulfills first wins the race.
Note that this behaves differently from Promise.race().
Promise.race() resolves or rejects based on whichever Promise settles first, success or failure.
Promise.any() instead waits specifically for the first Promise that fulfills successfully, ignoring rejections unless all of them fail.
That distinction may seem minor, but it can reshape how you handle errors entirely.
9. Cancel Work You No Longer Need
This pattern is one of the more satisfying ones to apply.
Think about a live search box:
user types: react
user types: react dashboard
user types: react dashboard ui
You probably don't want every earlier keystroke's request to keep running in the background once it's outdated.
This is exactly the situation AbortController was built for.
const controller = new AbortController();
fetch("/api/search?q=react", {
signal: controller.signal,
});
Once the request is no longer needed, you simply call:
controller.abort();
The request can then be cancelled.
This pattern shows up constantly in scenarios such as:
- Search suggestions
- Autocomplete fields
- Route or page transitions
- Component unmounting/cleanup
- Requests superseded by newer ones
The real skill here isn't just calling abort().
It's recognizing the moment earlier work stops being useful.
The Bigger Lesson
Promises don't feel hard because .then() is some intricate API.
They feel hard because real-world applications involve messy, layered asynchronous behavior.
You constantly need to work out things like:
Should these operations run at the same time?
What's the plan if one of them fails?
How long is too long to wait?
Is retrying worth it here?
How many operations should run concurrently?
Can I avoid duplicating work that's already underway?
Does this request still matter?
Once you're asking these kinds of questions, Promises stop being a mere language feature.
They turn into a genuine architectural tool.
My 9 Promise Patterns at a Glance
Promise.all() is best for running independent tasks together. Promise.allSettled() handles partial failures gracefully. Promise.race() is suited for timeouts or reacting to whichever settles first. Retry logic helps you recover from temporary failures. Delay and backoff strategies keep retries from becoming too aggressive. Concurrency limiting keeps large workloads under control. Request deduplication stops duplicate API calls. Promise.any() gets you the first successful result among several. AbortController lets you cancel work that's no longer necessary.
You don't need every one of these patterns in every project.
In fact, forcing them all in would be counterproductive.
The actual skill lies in identifying which problem you're solving before you reach for a particular pattern.
Final Thought
The strongest asynchronous code isn't the code stuffed with the cleverest Promise tricks.
It's the code where failure handling, timing, concurrency, and cancellation were thought through before they turned into production incidents.
Start with the simplest version.
Pay attention to what actually causes pain.
Only then bring in the specific pattern that addresses that pain point.
Because sometimes the most advanced move in JavaScript is recognizing when a pattern isn't needed at all.