Frontend / ECMAScript features / 07_promise_extensions.md

Promise Extensions and AbortSignal Statics

Updated 6 min read source
On this page6
  1. TL;DR
  2. In depth
  3. Gotchas / edge cases
  4. What a senior is expected to say
  5. Cross-references
  6. Further reading

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

In depth

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

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”
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 / 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 5

  • 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