frontend / es features / 07_promise_extensions.md

Promise Extensions and AbortSignal Statics

6 min read source

Promise Extensions and AbortSignal Statics

TL;DR

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).

Interview Q&A

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

A:

Resolves with Rejects when Use for
Promise.all array of all results any rejects (short-circuits) “all must succeed”
Promise.allSettled array of {status, value/reason} never “do them all, report each outcome”
Promise.any first fulfillment all reject (with AggregateError) “first success wins; failures don’t kill it”
Promise.race first to settle (fulfill or reject) first rejection if it’s first “whatever happens first”
// 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)),
]);

Q: When use allSettled over all?

A: 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.
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.

Q: Promise.any — when?

A: 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:

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

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

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

// 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).
// 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;
}

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

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

// 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:

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.

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

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

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.

Q: How does AbortSignal work with non-fetch APIs?

A: Many modern APIs accept { signal }:

// 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)
const timer = setTimeout(callback, 1000);   // no signal in browser setTimeout

// 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.

Q: Promise and unhandled rejections.

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

// 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.

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

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

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

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

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

Gotchas / edge cases

  • 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 chainp.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.

What a senior is expected to say

  • Promise.all for must-all-succeed; allSettled for must-do-all-report-each; any for first-success-wins; race for first-settles. Pick by failure tolerance.”
  • Promise.withResolvers (ES2024) replaces the deferred pattern — cleanest way to bridge callback APIs to async/await.”
  • AbortSignal.timeout(ms) over manual setTimeout + abort plumbing. AbortSignal.any composes multiple cancel sources (user, timeout, navigation).”
  • addEventListener accepts { signal } — abort the controller and the listener auto-removes. Underused, removes the cleanup-tracking boilerplate.”
  • “Unhandled rejection handler wired to your error tracker — uncaught rejections in prod are real bugs.”

Cross-references

Further reading