Asynchronous JavaScript Patterns

Introduction: Moving From Sync To Async

In our previous lessons, we explored the world of Symbols and Generators. We learned how to build lazy pipelines that process data only when we ask for it. While those pipelines are powerful, they are usually synchronous. This means the computer performs each step of the calculation immediately, one after another, without waiting for outside forces.

However, in real-world programming, we often have to deal with tasks that take an unpredictable amount of time. You might need to fetch a user's profile from a database, download an image from a website, or simply wait for a specific amount of time to pass. If we handled these tasks synchronously, our entire program would "freeze" until the task finished. To keep our applications smooth and responsive, we use Asynchronous JavaScript. In this lesson, we will learn how to manage these time-based tasks using Promises and the modern async/await syntax.

Creating Promises With Resolve And Reject

The foundation of asynchronous JavaScript is the Promise. You can think of a Promise as a placeholder for a value that does not exist yet but will be available in the future. When we create a new Promise, we provide a function that tells the Promise how to behave. This function receives two special tools: resolve and reject.

We call resolve when a task finishes successfully, passing along the result. We call reject if something goes wrong, passing along an error. Let’s look at two helper functions that use the setTimeout timer to simulate these outcomes. The delay function creates a Promise that succeeds after a few milliseconds, while the fail function creates a Promise that intentionally fails.

const delay = (ms, value) =>
  new Promise((resolve) => setTimeout(() => resolve(value), ms));

const fail = (ms, reason) =>
  new Promise((_, reject) => setTimeout(() => reject(new Error(reason)), ms));

In the delay function, we pass the ms (milliseconds) to setTimeout. Once that time passes, the code inside runs and calls resolve(value). This "fulfills" the Promise with the data we provided. In the fail function, we use the second argument of the Promise constructor, which is reject. After the time passes, it creates a new Error object and rejects the Promise, signaling that the operation failed.

Consuming Promises With .then() and .catch()

Before we look at modern syntax, it is important to understand how to "consume" or use a Promise. When a Promise is created, it is in a pending state. To get the data out once it is finished, we use the .then() method. To handle errors, we use the .catch() method.

delay(10, "Hello World")
  .then((result) => {
    console.log("Success:", result);
  })
  .catch((error) => {
    console.error("Failure:", error.message);
  });

The callback function inside .then() only runs if the Promise is resolved. If the Promise is rejected (like our fail helper would be), JavaScript skips the .then() block and runs the code inside .catch(). While this works well for simple tasks, nesting many .then() calls can lead to "callback hell," making the code difficult to read.

Async Functions And The Await Keyword

To make our code cleaner, JavaScript introduced the async and await keywords. This allows us to write asynchronous code that looks and behaves like synchronous code. When you mark a function with the async keyword, it automatically returns a Promise. Inside that function, you can use await before any Promise. This tells JavaScript to pause the execution of that specific function until the Promise is finished.

One common pattern you will see is the Async IIFE (Immediately Invoked Function Expression). Because top-level await is not always available in every environment, we often wrap our code in an anonymous async function and call it immediately.

(async () => {
  const result = await delay(10, "Hello World");
  console.log(result);
})();

Output:

Hello World

In this example, the code waits for 10 milliseconds for the delay to finish. Once the Promise resolves, the value Hello World is assigned to the result variable, and then it prints to the console. The rest of your program outside this function can keep running, so your application never feels frozen.

Sequential Execution With Await

When we use await on multiple lines, JavaScript runs them sequentially. This means the second task will not start until the first task is completely finished. This is very useful when the second task depends on the result of the first one. For example, you might need to get a user's ID before you can fetch their specific posts.

However, it is important to remember that this adds up the time for each task. If you have two tasks that take 10 milliseconds each, running them sequentially will take a total of 20 milliseconds.

(async () => {
  const a = await delay(10, "A");
  const b = await delay(10, "B");
  console.log("sequential:", a, b);
})();

Output:

sequential: A B

The code above waits for the first delay to resolve A, then starts the timer for the second delay to resolve B. In many cases, this is exactly what you want. But if the tasks do not depend on each other, running them one after another might be slower than necessary.

Promise.all And Promise.allSettled

If you have multiple tasks that can happen at the same time, you can use combinators. These are built-in methods that handle multiple Promises at once. The most common one is Promise.all. It takes an array of Promises and returns a single Promise that resolves with an array of all the results.

This is fail-fast, meaning if even one Promise in the group fails, the whole thing fails immediately. It is important to note that "fail-fast" only refers to the returned Promise.all result. The other tasks in the array continue to run in the background until they resolve or reject—they are not automatically cancelled. To stop pending operations like network requests or timers when one fails, you would need to use a separate mechanism like AbortController.

If you want to be more resilient and wait for every task to finish regardless of whether they succeed or fail, you can use Promise.allSettled. Instead of just giving you the values, it returns an array of objects describing the status of each Promise.

(async () => {
  const allResults = await Promise.all([delay(10, 1), delay(20, 2)]);
  console.log("all:", allResults);

  const settled = await Promise.allSettled([
    delay(5, "ok"),
    fail(10, "boom"),
  ]);
  console.log("settled:", settled.map((s) => s.status));
})();

Output:

all: [ 1, 2 ]
settled: [ 'fulfilled', 'rejected' ]

In the allSettled example, we can see that the first task was fulfilled and the second was rejected. This is very helpful when you want to try five different network requests and still process the ones that worked even if a few failed.

Promise.race And Promise.any

Sometimes you don't need all the results; you just need the first one that becomes available. JavaScript provides Promise.race and Promise.any for these situations. Promise.race is a literal race. It returns the result of the very first Promise to settle, whether that result is a success or a failure. If the fastest Promise fails, the race fails.

Promise.any is slightly different. It returns the first Promise that succeeds. If a Promise fails but others are still running, it will ignore the failure and wait for the next successful one. It only fails if every single Promise in the list fails.

(async () => {
  console.log("race:", await Promise.race([delay(20, "slow"), delay(5, "fast")]));
  console.log("any:",  await Promise.any([fail(5, "x"), delay(10, "winner")]));
})();

Output:

race: fast
any: winner

In the race example, fast wins because it only takes 5 milliseconds, while slow takes 20. In the any example, even though the first Promise fails at 5 milliseconds, Promise.any ignores it and waits for the winner, which arrives at 10 milliseconds.

Comparing Combinators

Choosing the right combinator depends on your goal: do you need every result, or just the first one? And how should a single failure affect the rest of your logic? While Promise.all is the most common for high-performance parallel tasks, Promise.allSettled is often safer when you need to ensure every task finishes regardless of the outcome. In contrast, race and any are useful when timing or redundancy are more important than receiving a complete dataset.

CombinatorResolves WhenRejects WhenReturnsUse Case
Promise.allAll fulfillAny rejectsArray of valuesIndependent tasks that all must succeed.
Promise.allSettledAll settleNeverArray of state objectsGathering results regardless of success/failure.
Promise.raceAny settlesAny settlesFirst value or errorImplementing timeouts or simple races.
Promise.anyAny fulfillsAll rejectFirst success valueRedundant sources where any success is fine.

Handling Errors With Try/Catch

When working with synchronous code, we use try/catch to handle errors. One of the best things about async/await is that it allows us to use that same familiar pattern for asynchronous errors instead of relying on .catch() chains. When a Promise is rejected, it acts just like a thrown error. By wrapping our await call in a try block, we can gracefully catch the failure in the catch block.

(async () => {
  try {
    await fail(5, "nope");
  } catch (e) {
    console.log("caught:", e.message);
  }
})();

Output:

caught: nope

Without the try/catch block, a rejected Promise would cause an unhandled rejection error, which could crash your program. Using this pattern makes your code much more stable and easier to read, as the error-handling logic sits right next to the code that might fail.

Summary And Practice Preview

In this lesson, we transitioned from the world of synchronous generators to asynchronous programming. We learned that Promises represent values that will arrive later and that we can consume them using .then() or the modern async and await keywords. We also explored several combinators like Promise.all and Promise.race to manage multiple tasks at once.

In the upcoming practice exercises, you will get to build your own asynchronous helpers and handle various success and failure scenarios. Let's move on to the exercises and put these Promise patterns into action!

Sign up

Join the 1M+ learners on CodeSignal

Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal