DEV Community

koushikmaya
koushikmaya

Posted on

# Understanding Async/Await, Promise Combinators, and Concurrency Limiting in JavaScript

Asynchronous programming is one of the most important concepts in JavaScript, especially when working with APIs, databases, file operations, and network requests.

In this blog, I will explain three important concepts:

  1. Async/Await
  2. Promise Combinators
  3. Concurrency Limiting

As part of my Week 05 Task 02, I also implemented custom versions of Promise.all(), Promise.race(), and Promise.allSettled(), along with a concurrency-limited task runner.


1. What is Asynchronous JavaScript?

JavaScript normally executes code from top to bottom:

console.log("Start");
console.log("Middle");
console.log("End");
Enter fullscreen mode Exit fullscreen mode

Output:

Start
Middle
End
Enter fullscreen mode Exit fullscreen mode

But some operations take time, such as:

  • API requests
  • Database queries
  • Reading files
  • Timers
  • Network requests

JavaScript uses Promises to represent the future result of these operations.

const promise = fetch("https://example.com");
Enter fullscreen mode Exit fullscreen mode

The request does not immediately give us the final response. Instead, it gives us a Promise that will eventually be fulfilled or rejected.


2. Promises

A Promise can be in one of three states:

Pending
   ↓
Fulfilled
Enter fullscreen mode Exit fullscreen mode

or:

Pending
   ↓
Rejected
Enter fullscreen mode Exit fullscreen mode

For example:

const promise = new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve("Data received");
  }, 1000);
});
Enter fullscreen mode Exit fullscreen mode

Initially the Promise is pending.

After one second, it becomes fulfilled with:

Data received
Enter fullscreen mode Exit fullscreen mode

We can consume it using .then():

promise.then((value) => {
  console.log(value);
});
Enter fullscreen mode Exit fullscreen mode

3. Async/Await

async and await provide a cleaner way to work with Promises.

Instead of:

fetchData()
  .then((data) => {
    console.log(data);
  })
  .catch((error) => {
    console.error(error);
  });
Enter fullscreen mode Exit fullscreen mode

We can write:

async function getData() {
  try {
    const data = await fetchData();
    console.log(data);
  } catch (error) {
    console.error(error);
  }
}
Enter fullscreen mode Exit fullscreen mode

The important thing to understand is that await does not block the entire JavaScript program.

It pauses the execution of the current async function until the Promise settles, while JavaScript can continue handling other work.


4. Sequential vs Parallel Async Operations

Consider three API requests:

const user = await getUser();
const posts = await getPosts();
const likes = await getLikes();
Enter fullscreen mode Exit fullscreen mode

These operations run sequentially.

If each takes one second, the total time can be approximately:

1 second + 1 second + 1 second = 3 seconds
Enter fullscreen mode Exit fullscreen mode

If the operations are independent, we can run them concurrently:

const [user, posts, likes] = await Promise.all([
  getUser(),
  getPosts(),
  getLikes(),
]);
Enter fullscreen mode Exit fullscreen mode

The total time can be closer to:

max(1s, 1s, 1s) = 1 second
Enter fullscreen mode Exit fullscreen mode

This is one of the main reasons Promise combinators are useful.


5. Promise Combinators

JavaScript provides several useful Promise combinators.

The three implemented in this task are:

  • Promise.all()
  • Promise.race()
  • Promise.allSettled()

6. Promise.all()

Promise.all() waits for all Promises to fulfill.

const result = await Promise.all([
  Promise.resolve("A"),
  Promise.resolve("B"),
  Promise.resolve("C"),
]);

console.log(result);
Enter fullscreen mode Exit fullscreen mode

Output:

["A", "B", "C"]
Enter fullscreen mode Exit fullscreen mode

An important property of Promise.all() is that it preserves the input order.

Even if the second operation finishes first, the result remains in the same order as the input.

Fail-fast behavior

If one Promise rejects:

await Promise.all([
  Promise.resolve("A"),
  Promise.reject("Something failed"),
  Promise.resolve("C"),
]);
Enter fullscreen mode Exit fullscreen mode

The returned Promise rejects.

This is called fail-fast behavior.

The other operations that have already started are not automatically cancelled.


7. My Custom Promise.all()

For this task, I implemented:

customAll(iterable)
Enter fullscreen mode Exit fullscreen mode

The implementation:

  1. Converts the iterable into an array.
  2. Handles an empty iterable.
  3. Processes every item.
  4. Stores results using the original index.
  5. Resolves only after everything fulfills.
  6. Rejects when an item rejects.

The index is important because it allows the implementation to preserve input order.

Conceptually:

results[index] = value;
Enter fullscreen mode Exit fullscreen mode

So completion order and result order can be different.


8. Promise.race()

Promise.race() settles when the first Promise settles.

const result = await Promise.race([
  delay(1000, "Slow"),
  delay(100, "Fast"),
]);

console.log(result);
Enter fullscreen mode Exit fullscreen mode

Output:

Fast
Enter fullscreen mode Exit fullscreen mode

For example:

Task A → 1000 ms
Task B → 200 ms
Task C → 500 ms

Winner → Task B
Enter fullscreen mode Exit fullscreen mode

The first fulfilled or rejected Promise determines the result.


9. My Custom Promise.race()

I implemented:

customRace(iterable)
Enter fullscreen mode Exit fullscreen mode

Each input is converted into a Promise-like value.

Then each one tries to settle the same resulting Promise.

Conceptually:

items.forEach((item) => {
  Promise.resolve(item).then(
    resolve,
    reject
  );
});
Enter fullscreen mode Exit fullscreen mode

Only the first settlement matters.

An empty iterable remains pending, matching the behavior of native Promise.race().


10. Promise.allSettled()

Sometimes we don't want the entire operation to fail just because one Promise failed.

That is where Promise.allSettled() is useful.

const result = await Promise.allSettled([
  Promise.resolve("Success"),
  Promise.reject("Failed"),
  Promise.resolve("Another success"),
]);
Enter fullscreen mode Exit fullscreen mode

The result looks like:

[
  {
    status: "fulfilled",
    value: "Success"
  },
  {
    status: "rejected",
    reason: "Failed"
  },
  {
    status: "fulfilled",
    value: "Another success"
  }
]
Enter fullscreen mode Exit fullscreen mode

Unlike Promise.all(), allSettled() waits for every operation.


11. When Should We Use Each Combinator?

Combinator Behavior
Promise.all() Wait for everything, fail if one rejects
Promise.race() Take the first settled result
Promise.allSettled() Wait for everything and report every result

Promise.all()

Useful when every operation is required.

Example:

Load user + permissions + profile
Enter fullscreen mode Exit fullscreen mode

Promise.race()

Useful when the first result matters.

Example:

Try multiple servers and use the first response.
Enter fullscreen mode Exit fullscreen mode

Promise.allSettled()

Useful when every result should be inspected.

Example:

Send notifications to 100 users and inspect which succeeded or failed.
Enter fullscreen mode Exit fullscreen mode

12. Concurrency

Now we come to another important concept: concurrency.

Imagine we have 100 tasks:

const tasks = [
  task1,
  task2,
  task3,
  // ...
  task100
];
Enter fullscreen mode Exit fullscreen mode

If we start all 100 at once, we could have:

100 tasks running
        ↓
High resource usage
        ↓
Too many network requests
        ↓
Possible API rate limits
        ↓
System overload
Enter fullscreen mode Exit fullscreen mode

Sometimes we need to control how many tasks can run at the same time.

That is called concurrency limiting.


13. What Does a Concurrency Limit Mean?

Suppose:

limit = 3
Enter fullscreen mode Exit fullscreen mode

This means:

At most 3 tasks are allowed to run at the same time.

The basic flow is:

Start 3 tasks
     ↓
One finishes
     ↓
Start the next task
     ↓
One finishes
     ↓
Start the next task
     ↓
Repeat until everything completes
Enter fullscreen mode Exit fullscreen mode

The important rule is:

active tasks <= concurrency limit
Enter fullscreen mode Exit fullscreen mode

14. Why Tasks Must Be Functions

One important design decision is that the task list contains functions, not already-created Promises.

Correct:

const tasks = [
  () => fetchUser(1),
  () => fetchUser(2),
  () => fetchUser(3),
];
Enter fullscreen mode Exit fullscreen mode

Why?

Because a function lets the concurrency limiter decide when the operation starts.

If we instead write:

const tasks = [
  fetchUser(1),
  fetchUser(2),
  fetchUser(3),
];
Enter fullscreen mode Exit fullscreen mode

the requests may already have started before the limiter gets control.

So the function acts like a delayed instruction:

Task function
     ↓
Wait in queue
     ↓
Limiter starts it
     ↓
Promise begins
Enter fullscreen mode Exit fullscreen mode

15. My Concurrency Limiter

I implemented:

runWithConcurrency(tasks, limit)
Enter fullscreen mode Exit fullscreen mode

For example:

const results = await runWithConcurrency(
  [
    () => delay(300, "A"),
    () => delay(100, "B"),
    () => delay(200, "C"),
    () => delay(150, "D"),
  ],
  2
);
Enter fullscreen mode Exit fullscreen mode

The limit is 2, so only two tasks can run simultaneously.

The basic algorithm is:

Start up to LIMIT tasks
        ↓
One task finishes
        ↓
Start the next queued task
        ↓
Repeat
        ↓
All tasks complete
Enter fullscreen mode Exit fullscreen mode

16. Preserving Result Order

Suppose tasks finish in this order:

Task 2
Task 3
Task 1
Task 4
Enter fullscreen mode Exit fullscreen mode

The returned result should still be:

[
  result1,
  result2,
  result3,
  result4
]
Enter fullscreen mode Exit fullscreen mode

To achieve this, the implementation stores every result using the task's original index:

results[index] = value;
Enter fullscreen mode Exit fullscreen mode

So completion order and result order can be different.


17. Handling Errors

The concurrency limiter rejects if a task fails.

For example:

const tasks = [
  () => successfulTask(),
  () => failingTask(),
  () => anotherTask(),
];
Enter fullscreen mode Exit fullscreen mode

If one task rejects, the returned Promise rejects.

However, tasks that were already running cannot generally be cancelled automatically.

This is an important distinction:

Promise rejection
        ≠
Automatic cancellation
Enter fullscreen mode Exit fullscreen mode

JavaScript Promises do not provide generic cancellation by themselves.

When supported by the underlying API, AbortController can be used for cancellation.


18. Input Validation

The implementation also validates the arguments.

For example, the concurrency limit must be a positive integer.

Invalid:

runWithConcurrency(tasks, 0);
runWithConcurrency(tasks, -1);
runWithConcurrency(tasks, 1.5);
Enter fullscreen mode Exit fullscreen mode

Valid:

runWithConcurrency(tasks, 3);
Enter fullscreen mode Exit fullscreen mode

The implementation also checks that every task is a function.


19. Testing the Implementation

The tests cover the main behaviors.

Promise.all

Tested:

  • Multiple fulfilled values
  • Input ordering
  • Rejection
  • Empty iterable
  • Native Promise compatibility

Promise.race

Tested:

  • Fastest settlement
  • Rejection
  • Empty iterable behavior

Promise.allSettled

Tested:

  • Fulfilled results
  • Rejected results
  • Mixed results
  • Input ordering
  • Empty iterable

Concurrency limiter

Tested:

  • Maximum concurrency
  • Result ordering
  • Task failures
  • Invalid limits
  • Invalid task values
  • Empty task arrays

Run the tests with:

node week-05/Task-02/test.js
Enter fullscreen mode Exit fullscreen mode

Expected output:

All Week 05 Task 02 tests passed.
Enter fullscreen mode Exit fullscreen mode

20. Concurrency vs Parallelism

These terms are related but not exactly the same.

Concurrency means multiple tasks can make progress during the same period.

Parallelism means tasks are literally executing at the same time, usually using multiple CPU cores or workers.

For JavaScript asynchronous I/O, we commonly talk about concurrency because multiple operations can be in progress while the JavaScript thread continues handling other work.


21. Real-World Example

Imagine an application needs to download 1,000 images.

A naive implementation might start everything immediately:

await Promise.all(
  images.map((image) => download(image))
);
Enter fullscreen mode Exit fullscreen mode

This can create a very large number of active operations.

A concurrency limiter can instead do:

await runWithConcurrency(
  images.map((image) => () => download(image)),
  5
);
Enter fullscreen mode Exit fullscreen mode

Now only five downloads are active at a time.

This gives us better control over resource usage and external service pressure.


22. What I Learned

Through this task, I learned that asynchronous programming is not only about writing async and `await.

The important concepts are:

  • How Promises work
  • How Promise states change
  • How Promise.all() coordinates multiple operations
  • How Promise.race() selects the first settlement
  • How Promise.allSettled() collects every outcome
  • Why input ordering matters
  • Why concurrency needs to be controlled
  • Why queued tasks should be functions
  • Why Promise rejection does not automatically cancel running operations
  • How to test asynchronous behavior reliably

The biggest takeaway for me was understanding the difference between:

text
Starting everything immediately

and:

text
Controlling when asynchronous work starts

That distinction becomes very important when building real applications.


23. Conclusion

Async/Await makes asynchronous JavaScript easier to read, but Promise combinators provide the tools needed to coordinate multiple asynchronous operations.

`text
Promise.all()
↓
Everything must succeed

Promise.race()
↓
First settlement matters

Promise.allSettled()
↓
Every result matters
`

Concurrency limiting adds another layer of control:

text
Large number of tasks
↓
Task queue
↓
Concurrency limit
↓
Controlled execution
↓
Ordered results

Understanding these concepts gives a strong foundation for working with APIs, databases, file operations, background jobs, and other asynchronous systems in JavaScript.


Repository

The implementation for this task is part of my Week 05 learning project.

Branch:

text
week-05-task-02-async-await-combinators

Task directory:

text
week-05/Task-02/

Files:

text
promise-combinators.js
concurrency-limiter.js
test.js
README.md


Final Takeaway

When working with asynchronous JavaScript, ask three questions:

  1. Do I need all operations to succeed?

    • Use Promise.all().
  2. Do I only care about the first result?

    • Use Promise.race().
  3. Do I need to know the result of every operation?

    • Use Promise.allSettled().

And when there are many tasks:

Don't just start everything. Control how much work runs at once.

That is the core idea behind concurrency limiting.

Top comments (0)