Home / Articles / Choosing Between Promise.all, Promise.race, and Sequential Awaits

This article is published in English.

Choosing Between Promise.all, Promise.race, and Sequential Awaits

Learn when Promise.all() speeds up Node.js APIs, why it fails fast on any rejection, and a decision framework for picking the right async pattern.

2267 words

Running asynchronous work in Node.js often starts out looking like a straightforward win: fire several operations at once instead of waiting on them in sequence, and your API gets faster. In practice, though, treating Promise.all() as a default habit rather than a deliberate choice can quietly turn a speed boost into a reliability problem. Knowing when operations are safe to parallelize, what should happen if one of them breaks, and when a different tool fits better matters just as much as knowing the syntax.

1. The Sequential Approach

Picture an API that has to pull together three things:

  • User information
  • Orders
  • Payments

A naive first pass might look like this:

const user = await getUser(userId);
const orders = await getOrders(userId);
const payments = await getPayments(userId);
return {
  user,
  orders,
  payments
};

This code runs fine. But look closely at the order of execution:

getUser()
   ↓
getOrders()
   ↓
getPayments()

Each step waits for the one before it to finish. The order fetch can't begin until the user fetch resolves, and the payments fetch can't begin until the orders fetch resolves. If each call takes roughly the same amount of time:

getUser()      = 200ms
getOrders()    = 200ms
getPayments()  = 200ms

then the total request time adds up to something like:

200 + 200 + 200 = 600ms

That's wasted time if these three calls have nothing to do with each other.

2. Enter Promise.all()

When operations don't depend on one another, you can launch them simultaneously instead:

const [user, orders, payments] = await Promise.all([
  getUser(userId),
  getOrders(userId),
  getPayments(userId)
]);
return {
  user,
  orders,
  payments
};

The execution flow now looks closer to this:

getUser()       ────────┐
                        │
getOrders()     ────────┤
                        ├──→ Promise.all()
getPayments()   ────────┘

Rather than paying the cost of:

A + B + C

your total wait time becomes roughly:

max(A, B, C)

So if each of the three calls still takes about 200ms:

Sequential:  ~600ms
Parallel:    ~200ms

That's a meaningful gain. But there's a nuance worth remembering:

Promise.all() doesn't speed up any individual operation.

It simply lets independent operations run concurrently instead of one after another.

3. A Real-World API Example

Consider a dashboard screen that needs to render:

Profile
Orders
Wishlist
Notifications

These could correspond to four separate database lookups or service calls. Written sequentially, it might look like this:

const profile = await getUserProfile(userId);
const orders = await getUserOrders(userId);const wishlist = await getUserWishlist(userId);const notifications = await getUserNotifications(userId);return {
  profile,
  orders,
  wishlist,
  notifications
};

Now compare that to the parallel version:

const [
  profile,
  orders,
  wishlist,
  notifications
] = await Promise.all([
  getUserProfile(userId),
  getUserOrders(userId),
  getUserWishlist(userId),
  getUserNotifications(userId)
]);
return {
  profile,
  orders,
  wishlist,
  notifications
}

Assuming those four calls truly don't depend on each other, this rewrite can cut down the API's total latency noticeably. It's one of the simplest wins available when optimizing Node.js performance.

But this isn't the end of the story — there's a catch you need to understand before applying this pattern everywhere.

4. Promise.all() Fails Fast

Here's the part that trips people up. Take this example:

const results = await Promise.all([
  getUser(),
  getOrders(),
  getPayments()
]);

What happens if getPayments() throws a rejection?

The whole Promise.all() call rejects, no matter how the other calls went:

getUser()       → SUCCESS
getOrders()     → SUCCESS
getPayments()   → ERROR
                ↓          Promise.all()
                ↓
             REJECT

You don't get a partial result with the two successful calls and a marker for the failed one. You get an exception, and none of the data comes through:

try {
  const [user, orders, payments] = await Promise.all([
    getUser(userId),
    getOrders(userId),
    getPayments(userId)
  ]);
} catch (error) {
  console.error(error);
}

This all-or-nothing behavior makes sense when every operation in the group is required for the response to be valid. But it's not always the right fit.

5. When Promise.all() Is the Wrong Choice

Say a dashboard needs to show:

  • Profile
  • Recommendations
  • Notifications

If the recommendations service happens to be down temporarily, should the whole dashboard fail to load? Almost certainly not. A better outcome would be something like:

Profile          → Available
Notifications    → Available
Recommendations  → Unavailable

This is exactly the situation Promise.allSettled() is built for:

const results = await Promise.allSettled([
  getUserProfile(userId),
  getRecommendations(userId),
  getNotifications(userId)
]);

Instead of stopping at the first rejection, it waits for every promise to settle and reports back on all of them:

[
  {
    status: "fulfilled",
    value: profile
  },
  {
    status: "rejected",
    reason: error
  },
  {
    status: "fulfilled",
    value: notifications
  }
]

From there, your application logic can decide how to treat each individual result. The distinction comes down to this:

Promise.all()
One fails
   ↓
Everything rejects

compared with:

Promise.allSettled()
One fails
   ↓
You still receive every result

Neither approach is inherently superior — they're designed to solve different problems, and picking the right one depends on whether a single failure should be treated as fatal for the whole group.

6. Chained Operations Shouldn't Be Forced Into Parallel

There's another trap worth calling out.

Say your workflow requires you to:

  1. Create a user
  2. Retrieve that user's ID
  3. Create an order tied to that user

These steps depend on one another.

You physically cannot create the order before the user record exists.

Which means writing something like this is broken:

await Promise.all([
  createUser(),
  createOrder()
]);

The order creation step likely needs the user's ID as input.

The right approach is to run these steps one after another:

const user = await createUser();
const order = await createOrder(user.id);

The guiding principle here is straightforward:

Operations with no relationship to each other are candidates for parallel execution.

Operations that rely on each other's output must run in sequence.

Don't reach for parallelism just because the language lets you.

7. Unlimited Parallelism Can Overwhelm Your System

There's a subtler issue that's easy to overlook.

Consider this snippet:

await Promise.all(
  users.map(user => sendEmail(user.email))
);

With 10 users, this is unlikely to cause any trouble.

With 10,000 users, you're now launching thousands of simultaneous operations.

More concurrency doesn't automatically translate into better performance.

You could run into:

  • Limits on database connections
  • API rate ceilings
  • Memory strain
  • Network congestion
  • Restrictions imposed by third-party services
  • A spike in error rates

What you often need instead of unbounded parallel execution is bounded concurrency.

One way to achieve that is with a concurrency-limiting library:

import pLimit from "p-limit";
const limit = pLimit(5);const results = await Promise.all(
  users.map(user =>
    limit(() => sendEmail(user.email))
  )
);

With this setup, no more than five operations execute at the same time.

Visually, the difference looks like this:

1000 tasks
     ↓Concurrency limit = 5     ↓5 tasks
5 tasks
5 tasks
5 tasks
...

This is slower than launching all 1,000 tasks in one go.

But it gives you far more control.

And in real-world conditions, controlled execution frequently performs better overall, since it avoids saturating the resources your operations depend on.

8. Promise.race() Solves a Different Problem

There's another method that gets mixed up with Promise.all():

Promise.race()

Promise.race() settles — resolving or rejecting — the moment the first promise among the group settles.

For instance:

const result = await Promise.race([
  serverA(),
  serverB()
]);

Visually:

Server A ───────────────→ 500ms
Server B ───────→ 200ms                    ↓
               Promise.race()
                    ↓
                 Result

This pattern has legitimate uses, such as racing redundant requests against each other or implementing timeout behavior.

However, keep this in mind:

Promise.race() does not automatically stop the operations that lose the race.

If you need to cancel the losing operations, you have to implement that yourself, typically with something like AbortController.

9. Don't Forget About Retry Behavior

Suppose you're making a call to an external service:

const result = await paymentService();

The call fails due to a transient network hiccup.

If you wrap everything in a large Promise.all() and blindly retry on failure, you can introduce a new issue.

You risk triggering a retry storm.

Before retrying, consider:

  • Which operations are actually safe to retry?
  • How many retry attempts should be allowed?
  • How long should you wait between attempts?
  • Is the operation idempotent?
  • What if the external service is already struggling under load?

A common pattern for transient errors is exponential backoff.

Conceptually:

Attempt 1 → fail
     ↓
   wait
     ↓
Attempt 2 → fail
     ↓
  wait longer
     ↓
Attempt 3 → success

Speed means little if it undermines reliability.

10. Apply the Same Reasoning to Database Queries

It's tempting to think that because JavaScript supports concurrent promises, database queries should always be fired off in parallel.

That's not always true.

Take this example:

await Promise.all([
  database.users.findMany(),
  database.orders.findMany(),
  database.products.findMany(),
  database.payments.findMany(),
  database.notifications.findMany()
]);

This launches roughly five database operations at once.

That could be totally fine.

Or it could push your database past its limits during peak traffic.

Factors worth considering include:

  • How complex each query is
  • The size of your database connection pool
  • How many API instances are running
  • Overall traffic volume
  • Whether proper indexes exist
  • How long each query takes to execute
  • CPU and memory headroom on the database server

Performance tuning can't happen in a vacuum.

Your API is one piece of a much larger system.

11. A Framework for Deciding When to Reach for Promise.all()

Before applying Promise.all(), it helps to run through three questions.

Question 1: Are the operations independent?

If they are, running them in parallel may be worthwhile.

If they aren't, preserve the sequence they depend on.

Question 2: What should happen if one operation fails?

If a single failure should invalidate the entire batch:

Promise.all()

is likely the right tool.

If you'd rather collect whatever results succeed:

Promise.allSettled()

tends to be the better fit.

Question 3: How many operations are being launched at once?

Three concurrent calls?

That's generally manageable.

Ten thousand?

That's an entirely different challenge.

You may need to introduce:

  • Concurrency caps
  • Batching
  • Queueing
  • Pagination
  • Rate limiting

12. A Quick Reference for Choosing an Approach

Situation Better Approach
Independent operations, all must succeed Promise.all()
Independent operations, partial success is okay Promise.allSettled()
Operations depend on one another Sequential await
You just need whichever finishes first Promise.race()
Many tasks requiring bounded concurrency p-limit or batching
Slow, non-urgent background work Queue or worker process
External calls prone to transient failures Retry logic with backoff

What matters isn't memorizing this table.

It's grasping the reasoning behind each choice.

13. The Core Takeaway

When first encountering Promise.all(), it's natural to assume:

"Running things in parallel is always faster."

That assumption doesn't hold up.

A more accurate way to think about it is:

Parallel execution only pays off when the system underneath it can actually absorb the concurrent load.

If you have three independent operations that each take 100ms:

Sequential → ~300ms
Parallel   → ~100ms

The improvement is obvious.

But scale that up to 10,000 operations against a database limited to 100 concurrent connections, and unrestrained parallelism can degrade performance instead of improving it.

A better question to ask yourself is:

"Can I run these in parallel?"Ask:"Should I run these in parallel?"

That shift in thinking separates knowing the syntax of JavaScript from actually understanding how backend systems behave under load.

Final Takeaway

Promise.all() remains one of the most valuable tools in Node.js for executing independent asynchronous work concurrently.

That said, it isn't a guaranteed speed boost.

Reach for it when:

  • The operations don't depend on each other
  • You genuinely need every result
  • Your system can absorb the added concurrency

Reach for Promise.allSettled() when it's acceptable for some operations to fail without derailing the rest.

Reach for sequential await calls when operations depend on one another's output.

Reach for concurrency limits when you're handling a large number of tasks at once.

And reach for queues when the work doesn't need to complete within the lifespan of the HTTP request.

The objective isn't just to shave milliseconds off your code.

The objective is to make your system faster without making it brittle.