Mert Özen Further With Every Line
Frontend JavaScript Advanced — #05

Advanced JavaScript Async Patterns: The Promise.all Family, Cancellation, Retry and Timeouts

Mert Özen Nov 3, 2026 9 min 5 views
Advanced JavaScript Async Patterns: The Promise.all Family, Cancellation, Retry and Timeouts

We look at the differences between Promise.all, allSettled, any and race, ES2024's Promise.withResolvers, ES2025's Promise.try, supporting AbortSignal in your own functions, retry, timeouts and concurrency limits.

Version note: The code in this post targets ES2026 and current browsers (Chrome, Edge, Firefox, Safari).

Predict First

There are three promises: one succeeds after 100 milliseconds, one is rejected instantly, and one succeeds after 300 milliseconds. We hand the same trio to two different combinators. What gets printed to the console, and in what order?

const fast = new Promise((resolve) => setTimeout(() => resolve("fast"), 100));
const broken = Promise.reject(new Error("broken"));
const slow = new Promise((resolve) => setTimeout(() => resolve("slow"), 300));

Promise.any([broken, fast, slow]).then((value) => console.log("any:", value));

Promise.race([broken, fast, slow]).then(
  (value) => console.log("race:", value),
  (error) => console.log("race error:", error.message)
);

Make a guess before you keep reading. A hint: any and race sound very similar, but they define "the winner" differently.

Four Combinators, Four Different Questions

We took a brief look at Promise.all and Promise.race earlier. There are actually four combinators, and each one answers a different question. Think of a team handing in a project. Promise.all asks "did everyone deliver?"; if even one fails, it rejects right away. Promise.allSettled asks "has everyone come back with a result?"; success or failure doesn't matter, it waits for all of them and gives you a report. Promise.any asks "did at least one succeed?"; it takes the first success and only cares about errors if all of them fail. Promise.race just asks "who came back first?"; success or error, the first result wins.

Now we can go back to the opening question. First race error: broken is printed, and about 100 milliseconds later any: fast. Since broken is an already rejected promise, it's the first to settle in race; as we know from the previous post, the result arrives through a microtask, before any of the timers. any ignores the rejected promise and waits for the first successful one, which is fast.

Promise.allSettled arrived with ES2020 and Promise.any with ES2021. The output of allSettled is ideal for seeing which of several requests failed:

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

const results = await Promise.allSettled([
  delay(50, "profile"),
  Promise.reject(new Error("notification service is down")),
  delay(80, "messages")
]);

for (const result of results) {
  if (result.status === "fulfilled") console.log("ok:", result.value);
  else console.log("error:", result.reason.message);
}

Here we used await outside a function. The console allows that; to do the same in an HTML file, the script has to be type="module". With Promise.any, if every promise is rejected, the result is an AggregateError, and the individual errors sit in its errors array.

Let's be clear about one thing: none of these combinators stops the losers. When Promise.all rejects on the first error, the other requests keep running in the background; it's just that nobody cares about their results anymore. If you really want to stop them, you need a cancellation mechanism; we'll get to that shortly.

Promise.withResolvers: Controlling a Promise from Outside

Sometimes the place where you create a promise and the place where you settle it are different. We used to write a pattern for that: declare two let variables outside, then assign resolve and reject to them inside new Promise. Promise.withResolvers, which came with ES2024, cuts that pattern down to one line:

function nextClick(element) {
  const { promise, resolve } = Promise.withResolvers();
  element.addEventListener("click", resolve, { once: true });
  return promise;
}

console.log("Click anywhere on the page...");
const event = await nextClick(document.body);
console.log("clicked:", event.clientX, event.clientY);

Paste this into the console, then click somewhere inside the page. We passed the resolve function directly as the event listener; on the first click it's called with the event object and the promise resolves. This pattern comes in very handy whenever you want to turn an event, a user confirmation or the arrival of a message into a promise.

Promise.try: Catching Synchronous Errors Too

Sometimes you have a function that returns synchronously, sometimes returns a promise, and sometimes throws before it even gets going. Getting it into a promise chain is harder than you'd think:

function loadConfig(text) {
  if (!text) throw new Error("config is empty");
  return JSON.parse(text);
}

// Promise.resolve(loadConfig("")) → throws before it ever reaches the chain

Promise.try(loadConfig, "")
  .then((config) => console.log(config))
  .catch((error) => console.log("caught:", error.message));
// "caught: config is empty"

Had you written Promise.resolve(loadConfig("")), loadConfig would throw while the argument was being evaluated, and the error would never reach catch. Promise.try, which came with ES2025, calls the function itself and wraps whatever happens, a returned value, a returned promise or a thrown error, into a single promise. It also passes extra arguments on to the function. The difference from the Promise.resolve().then(fn) trick is that fn runs right away, synchronously, not one microtask later. All current browsers support it; if you also target older versions, don't forget a feature check.

Cancellation: Supporting AbortSignal in Your Own Functions

We saw earlier how to cancel fetch requests with AbortController. The mechanism isn't specific to fetch; you can apply the same contract in your own asynchronous functions. The rule is simple: the function accepts a signal, checks whether it was already aborted before starting, drops its work and rejects with signal.reason if an abort comes in, and cleans up its listener when the work is done.

function wait(ms, { signal } = {}) {
  return new Promise((resolve, reject) => {
    signal?.throwIfAborted();

    const onAbort = () => {
      clearTimeout(timerId);
      reject(signal.reason);
    };

    const timerId = setTimeout(() => {
      signal?.removeEventListener("abort", onAbort);
      resolve();
    }, ms);

    signal?.addEventListener("abort", onAbort, { once: true });
  });
}

const controller = new AbortController();

wait(5000, { signal: controller.signal })
  .then(() => console.log("done"))
  .catch((error) => console.log("aborted:", error.name)); // "aborted: AbortError"

controller.abort();

throwIfAborted throws the reason if the signal has already been aborted; since we're inside the promise executor, that throw automatically turns into a rejection. When we call abort() without an argument, the browser creates a DOMException named AbortError as the reason. If your function contains other cancellable work such as a fetch, pass the same signal along to it; that way a single abort() call stops the whole chain.

Timeouts: Putting a Time Limit on Work

The first solution that comes to mind is racing the work against a timer:

function withTimeout(promise, ms) {
  let timerId;
  const timeout = new Promise((_, reject) => {
    timerId = setTimeout(() => reject(new Error(`did not finish within ${ms} ms`)), ms);
  });
  return Promise.race([promise, timeout]).finally(() => clearTimeout(timerId));
}

withTimeout(wait(2000), 500).catch((error) => console.log(error.message));
// "did not finish within 500 ms"

It works, but it has a gap: when time runs out, you stop waiting for the result, while the work carries on. A fetch request keeps downloading in the background. The proper way is to hand the time limit to the work itself as an abort signal. The AbortSignal.timeout(ms) we saw earlier does exactly that. If you want to support both the user's cancel button and a time limit, you combine the two signals with AbortSignal.any; whichever fires first aborts the combined signal too:

const userController = new AbortController();
const signal = AbortSignal.any([userController.signal, AbortSignal.timeout(500)]);

wait(2000, { signal }).catch((error) => console.log(error.name));
// "TimeoutError"

On a timeout, the reason is a DOMException named TimeoutError. That lets you tell a user cancellation apart from time running out.

Retry: Try Again, but Wisely

Network requests can fail for temporary reasons. Retrying immediately and constantly, though, piles load onto a server that's already struggling. The right pattern is exponential backoff: double the wait after every attempt, and add a bit of randomness (jitter) on top so that thousands of clients don't retry at the same moment.

async function retry(task, { retries = 3, baseDelay = 200, signal } = {}) {
  for (let attempt = 0; ; attempt++) {
    try {
      return await task({ attempt, signal });
    } catch (error) {
      if (attempt >= retries || signal?.aborted) throw error;
      const backoff = baseDelay * 2 ** attempt;
      const jitter = Math.random() * baseDelay;
      await wait(backoff + jitter, { signal });
    }
  }
}

let calls = 0;
const flaky = async () => {
  calls += 1;
  if (calls < 3) throw new Error(`attempt ${calls} failed`);
  return "finally worked";
};

console.log(await retry(flaky), calls); // "finally worked" 3

This code uses the wait function from above, so you need to have pasted that first. The await in the return await line isn't decoration. Remove it, and when the promise returned by task rejects, the error happens outside the try block, catch never sees it, and there's no retry. Also pay attention to what you retry: a connection error or a temporary status like 503 is worth another attempt, a 404 or 400 isn't; the same request will get the same answer. And never automatically retry operations that take effect twice when repeated, such as payments, unless the server is built for it.

Limiting Concurrency: At Most N Jobs at Once

You have 200 image URLs and want to download them all. Promise.all(urls.map(fetch)) starts all 200 requests at once. The browser already limits the number of connections it opens to the same server, but all 200 requests still sit in memory, the server gets hit all at once, and the user's genuinely important request ends up at the back of the line. The solution is a small pool that limits how many jobs run at the same time:

async function mapWithLimit(items, limit, worker) {
  const results = new Array(items.length);
  let next = 0;

  async function runner() {
    while (next < items.length) {
      const index = next++;
      results[index] = await worker(items[index], index);
    }
  }

  const runners = Array.from({ length: Math.min(limit, items.length) }, runner);
  await Promise.all(runners);
  return results;
}

let active = 0;
const output = await mapWithLimit([1, 2, 3, 4, 5, 6, 7], 2, async (id) => {
  active += 1;
  console.log(`start ${id} (running: ${active})`);
  await wait(300);
  active -= 1;
  return id * 10;
});

console.log(output); // [10, 20, 30, 40, 50, 60, 70]

The idea: you start limit runners, and each one picks up the next item from the list when it finishes a job. In the output you'll see that the running count never goes above 2. Results land in list order, not in the order the jobs finished, because each result is written to its own index.

But can't two runners grab the same next value? They can't. As we saw in the previous post, JavaScript runs on a single thread, and a piece of code runs uninterrupted until it reaches an await. Since there's no await between the next < items.length check and next++, no other runner can cut in.

Common Pitfalls

Leaving promises floating. If you call an async function without awaiting it or attaching a catch, a rejection reaches nobody; the browser just writes an "Uncaught (in promise)" to the console and fires an unhandledrejection event. Either wait for it or deliberately attach a catch.

Forgetting how empty arrays behave. Promise.all([]) resolves immediately with an empty array, Promise.any([]) rejects immediately with an AggregateError, and Promise.race([]) never settles. If you pass a dynamic list to race, your code can wait forever when the list is empty.

Thinking a race-based timeout stops the work. We saw it above: time runs out, the work goes on. To really stop it, use a signal.

Not cleaning up abort listeners. If you call addEventListener on a long-lived signal on every call and never remove the listener when the work is done, listeners pile up. That's why the wait function calls removeEventListener when the timer fires; we'll talk about memory leaks separately in upcoming posts.

Not checking for errors after allSettled. allSettled never rejects; that doesn't mean there were no errors. If you don't look at the status field of each result, errors get swallowed silently.

A Small Experiment

★☆☆ Predict before running: Promise.all([delay(100, "a"), Promise.reject(new Error("b")), delay(50, "c")]).then(console.log, (e) => console.log("error:", e.message)); What gets printed, and after how many milliseconds? Try the same array with allSettled and compare the output.

★★☆ Add a shouldRetry(error) option to the retry function. By default it should retry on every error, but when you use it with a fetch wrapper, make it retry only on network errors and 5xx status codes.

★★★ Add signal support to mapWithLimit: when the signal is aborted, no new jobs should start, the running jobs should receive the same signal, and the function should reject with signal.reason. Then try it on a list of 20 jobs with an AbortSignal.timeout that aborts on its own after five seconds.

In this post we combined promises that each produce a single value. In the next post we move to a different question: producing values not all at once but one by one, as they're needed. The iterator protocol, Symbol.iterator, generator functions, yield and the iterator helpers that came with ES2025.