Frontend / ECMAScript features / 07_promise_extensions.md

Promise Extensions and AbortSignal Statics

Updated 5 interview angles 6 min read source
On this page13
  1. Promise.all vs allSettled vs any vs race — quick comparison.
  2. When use allSettled over all?
  3. Promise.any — when?
  4. Promise.withResolvers (ES2024) — what’s it replace?
  5. AbortSignal.timeout(ms) — what’s it for?
  6. AbortSignal.any([signals]) — composing aborts.
  7. How does AbortSignal work with non-fetch APIs?
  8. Promise and unhandled rejections.
  9. What about Promise.try (Stage 4 / ES2025)?
  10. Gotchas
  11. Related
  12. Further reading
  13. Interview angle

Promise Extensions and AbortSignal Statics

Promise got new static methods over the past few years: Promise.allSettled (waits for all, never rejects), Promise.any (first to fulfill wins), Promise.withResolvers (clean manual Promise construction). AbortSignal got AbortSignal.timeout(ms) and AbortSignal.any([signals]) for cancellation composition. These small additions replace common manual patterns (deferred objects, racing for “first success,” composing aborts).

Promise.all vs allSettled vs any vs race — quick comparison.

Settles with Use for
Promise.all all results, or the first rejection “all must succeed”
Promise.allSettled {status, value/reason} per input; never rejects “do them all, report each outcome”
Promise.any first fulfillment; AggregateError if all reject “first success wins”
Promise.race whichever settles first, either way “whatever happens first”
ts
// allSettled — common for "fetch many things, render what worked"
const results = await Promise.allSettled([
  fetch("/api/a"),
  fetch("/api/b"),
  fetch("/api/c"),
]);
const successful = results.filter(r => r.status === "fulfilled").map(r => r.value);

// any — first mirror to respond wins
const fastest = await Promise.any([
  fetch("https://cdn1.example.com/file"),
  fetch("https://cdn2.example.com/file"),
  fetch("https://cdn3.example.com/file"),
]);

// race — timeouts (often replaced by AbortSignal.timeout now)
const winner = await Promise.race([
  fetch("/api/slow"),
  new Promise((_, rej) => setTimeout(() => rej(new Error("timeout")), 5000)),
]);

When use allSettled over all?

When partial failure is acceptable and you need to know which succeeded:

  • Loading a dashboard’s multiple widgets — render the ones that loaded.
  • Bulk operations where each item is independent.
  • Status checks across multiple services.
ts
const responses = await Promise.allSettled(urls.map(u => fetch(u)));
const failures = responses.filter(r => r.status === "rejected");
if (failures.length) reportPartialFailure(failures);

Use all only when every promise must succeed for the operation to proceed.

Promise.any — when?

Racing redundant attempts where any success counts:

  • Multi-region failover — try 3 region URLs in parallel; first one to return is your answer.
  • Cached + network — try cache lookup and network in parallel; first response wins.
  • Geolocation — try GPS, IP, last-known in parallel.

The reject path is AggregateError:

ts
try {
  const x = await Promise.any(promises);
} catch (err) {
  if (err instanceof AggregateError) {
    // array of individual errors
    console.log("all failed:", err.errors);
  }
}

Promise.withResolvers (ES2024) — what’s it replace?

The deferred pattern: create a Promise and access its resolve/reject from outside.

ts
// Old — verbose
function deferred<T>() {
  let resolve!: (v: T) => void, reject!: (e: unknown) => void;
  const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej; });
  return { promise, resolve, reject };
}

// ES2024
const { promise, resolve, reject } = Promise.withResolvers<string>();
setTimeout(() => resolve("done"), 1000);
await promise;

Useful for:

  • Bridging callback APIs to async/await.
  • “Wait for an external event” patterns.
  • Event-based futures (next message, next click).
ts
// Wait for the next WebSocket message
function nextMessage(socket: WebSocket): Promise<string> {
  const { promise, resolve } = Promise.withResolvers<string>();
  socket.addEventListener("message", (e) => resolve(e.data), { once: true });
  return promise;
}

AbortSignal.timeout(ms) — what’s it for?

A signal that aborts after a timeout — saves the manual setTimeout + controller.abort() plumbing:

ts
// Before
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5000);
try {
  const res = await fetch(url, { signal: controller.signal });
} finally { clearTimeout(timer); }

// After (ES2022)
const res = await fetch(url, { signal: AbortSignal.timeout(5000) });

Cleaner. Don’t forget to handle AbortError:

ts
try {
  await fetch(url, { signal: AbortSignal.timeout(5000) });
} catch (e) {
  if (e instanceof DOMException && e.name === "TimeoutError") {
    // it timed out
  }
}

TimeoutError is the specific abort reason AbortSignal.timeout uses.

AbortSignal.any([signals]) — composing aborts.

Combines multiple signals into one that fires when any of them does:

ts
const userCancel = new AbortController();

const res = await fetch(url, {
  signal: AbortSignal.any([
    userCancel.signal,                  // user clicked cancel
    AbortSignal.timeout(10_000),        // overall timeout
    routeChangeController.signal,       // user navigated away
  ]),
});

// Any of the three aborting cancels the fetch.

Eliminates the need for the “compose multiple aborts” pattern most fetch wrappers had to roll by hand.

How does AbortSignal work with non-fetch APIs?

Many modern APIs accept { signal }:

ts
// addEventListener — auto-removes when signal aborts
const controller = new AbortController();
window.addEventListener("scroll", handler, { signal: controller.signal });
// Later: controller.abort() — handler is removed automatically. No manual removeEventListener.

// setTimeout (Node only — not browser yet)
// no signal in browser setTimeout
const timer = setTimeout(callback, 1000);

// Streams API
stream.pipeTo(dest, { signal: controller.signal });

// ReadableStream consumer
const reader = stream.getReader({ signal });

The signal option on addEventListener is underused — removes the need to track listener references for cleanup.

Promise and unhandled rejections.

A Promise that rejects without a .catch or await produces an unhandled rejection event:

ts
// Browser
window.addEventListener("unhandledrejection", (e) => {
  console.warn("unhandled:", e.reason);
  e.preventDefault();   // suppress default warning
});

// Node
process.on("unhandledRejection", (reason) => {
  console.error("unhandled rejection:", reason);
});

For monitoring (Sentry/Datadog), wire to the error tracker. Unhandled rejections in production usually signal a real bug — a missing await on a fire-and-forget that should have logged its failure.

What about Promise.try (Stage 4 / ES2025)?

Wraps a function call (sync or async) in a Promise, catching sync throws too:

ts
// Without — sync throws escape
Promise.resolve().then(() => syncFunctionThatMightThrow());   // ok
Promise.resolve(syncFunctionThatMightThrow());                // sync throw uncaught

// With Promise.try
const promise = Promise.try(syncFunctionThatMightThrow);
// catches both sync throws and async rejections
promise.catch(handleError);

Useful when you don’t know if a function is sync or async, or want uniform error handling.

Gotchas

  • Promise.race with no fulfillment — hangs forever if no promise resolves. Pair with AbortSignal.timeout or a backup promise that rejects after a deadline.
  • Promise.all short-circuits on rejection — the other promises still run (you can’t cancel them via all). For cancel-on-first-error, you need explicit AbortController in each.
  • Unhandled rejection in .then chain — p.then(a).then(b) where b rejects has no handler. Always end chains with .catch (or await).
  • AbortSignal.timeout browser support — landed in 2022; safe in evergreens. Polyfill for older.
  • AbortSignal.any browser support — newer (2024); polyfill via abort-controller.
  • AbortController reuse — once aborted, the controller’s signal stays aborted. Use a new controller per logical operation.
  • Race conditions in withResolvers — calling resolve after reject (or vice versa) does nothing. Same as new Promise((res, rej) => ...) semantics.

Further reading

Interview angle 5

  • “Which combinator, and why?” - by failure tolerance. all when everything must succeed, allSettled when everything must be attempted and reported, any when the first success wins and failures are acceptable, race when you want whichever settles first even if that is a rejection.
  • “How do you bridge a callback API to async/await?” - Promise.withResolvers() (ES2024), which returns the promise together with its resolve and reject. It replaces the deferred pattern of declaring two variables outside an executor and assigning them inside it.
  • “How do you put a timeout on a fetch?” - AbortSignal.timeout(ms), with no manual setTimeout and controller.abort() plumbing.
    • Follow-up: “And if the user can also cancel?” - AbortSignal.any([...]) composes several cancellation sources - a user action, a timeout, a route change - into one signal, so the operation aborts on whichever fires first.
  • “What is the most underused thing AbortSignal does?” - addEventListener(type, fn, { signal }). Aborting the controller removes the listener, so one abort() tears down every listener registered with that signal and the bookkeeping disappears.
  • “Why wire up unhandledrejection?” - because a rejected promise with no handler fails silently otherwise. It is the async equivalent of an uncaught exception, and without that listener it never reaches your error tracker.