“You need to load a user, their orders and their recommendations. Which Promise method do you use, and what happens if recommendations fail?” Interviewers love the four Promise combinators because each one encodes a different failure policy, and choosing between them forces you to think about what the product should do when part of the work goes wrong. Candidates who answer “Promise.all” for everything usually get a follow-up showing the whole page breaking because one optional widget timed out.
The question also checks edge cases that rarely come up until production: empty arrays, non-promise values in the input, and the fact that a rejected combinator does not stop the other operations.
Before you start
You should understand promise states (pending, fulfilled, rejected), async/await, and try/catch around await. Knowing that an unhandled rejection is reported by the runtime will help with the “losing promises” discussion.
The short answer
Promise.all fulfils with an array of all results in input order, or rejects as soon as any input rejects. Promise.allSettled always fulfils, with an array of { status, value } or { status, reason } objects, once every input has settled. Promise.race settles the same way as whichever input settles first, success or failure. Promise.any fulfils with the first success and rejects with an AggregateError only if every input rejects. None of them cancel the remaining work.
How it works
All four accept any iterable, wrap each element with Promise.resolve (so plain values are allowed), and attach handlers to every element immediately. They differ only in when they settle and with what.
const delay = (ms, value, fail = false) =>
new Promise((resolve, reject) => setTimeout(() => (fail ? reject(new Error(value)) : resolve(value)), ms));
const inputs = () => [delay(30, 'slow'), delay(10, 'fast-fail', true), delay(20, 'medium')];
await Promise.all(inputs()).catch((e) => console.log('all rejected:', e.message));
console.log('allSettled:', (await Promise.allSettled(inputs())).map((r) => r.status));
await Promise.race(inputs()).catch((e) => console.log('race rejected:', e.message));
console.log('any:', await Promise.any(inputs()));
// all rejected: fast-fail
// allSettled: [ 'fulfilled', 'rejected', 'fulfilled' ]
// race rejected: fast-fail
// any: mediumHere fast-fail rejects first at 10 ms. all and race both reject with it; any ignores it and waits for the first success, medium at 20 ms; allSettled reports every outcome after 30 ms.
Empty input is a classic edge case, and each method behaves differently:
console.log(await Promise.all([])); // []
console.log(await Promise.allSettled([])); // []
await Promise.any([]).catch((e) => console.log(e.constructor.name, e.errors)); // AggregateError []
const pending = Promise.race([]);
const timeout = new Promise((r) => setTimeout(() => r('race([]) still pending'), 10));
console.log(await Promise.race([pending, timeout])); // race([]) still pendingPromise.race([]) never settles, because there is nothing to race. Code that builds the input list dynamically can hang forever if that list turns out empty.
Step-by-step walkthrough
Step 1: Classify each operation
For each async call, decide whether it is required or optional, and whether you need all results or just one. Required and all-or-nothing points to all. Independent tasks where partial success is useful points to allSettled. “Whichever responds first” with fallbacks points to any. “First thing to happen, including failure,” like a timeout, points to race.
Step 2: Load required data with Promise.all
When the page cannot render without every piece, failing fast is correct: no reason to wait for the others once one essential call has failed.
const api = {
user: async () => ({ name: 'Ada' }),
orders: async () => [{ id: 1 }, { id: 2 }],
};
const [user, orders] = await Promise.all([api.user(), api.orders()]);
console.log(user.name, orders.length); // Ada 2Destructuring keeps the code readable, and order is guaranteed to match the input, not completion order.
Step 3: Collect partial results with allSettled
For a dashboard where each widget is independent, render what succeeded and show an error state for what failed.
const widgets = {
weather: async () => '18°C',
news: async () => { throw new Error('news API 503'); },
stocks: async () => 'up 2%',
};
const names = Object.keys(widgets);
const results = await Promise.allSettled(names.map((n) => widgets[n]()));
const view = Object.fromEntries(
results.map((r, i) => [names[i], r.status === 'fulfilled' ? r.value : `error: ${r.reason.message}`]),
);
console.log(view);
// { weather: '18°C', news: 'error: news API 503', stocks: 'up 2%' }Step 4: Use any for redundancy and race for deadlines
any suits mirrored sources: take whichever CDN or replica answers successfully first. race suits deadlines, because a timeout is just a promise that rejects after a delay.
const delay = (ms, v, fail) => new Promise((res, rej) => setTimeout(() => (fail ? rej(new Error(v)) : res(v)), ms));
const mirror = await Promise.any([delay(5, 'mirror-a down', true), delay(15, 'mirror-b'), delay(25, 'mirror-c')]);
console.log(mirror); // mirror-b
const withTimeout = (p, ms) => Promise.race([p, delay(ms, `timed out after ${ms}ms`, true)]);
await withTimeout(delay(50, 'slow report'), 20).catch((e) => console.log(e.message));
// timed out after 20msWhen every any input fails, inspect error.errors on the AggregateError to see each individual reason.
Worked scenario
A product page used one Promise.all for the product, its price and a “customers also bought” carousel. When the recommendation service had an outage, every product page showed an error, even though the essential data loaded fine.
const product = async () => ({ title: 'Lamp' });
const price = async () => 25;
const recommendations = async () => { throw new Error('recs timeout'); };
async function pageBroken() {
try {
const [p, cost, recs] = await Promise.all([product(), price(), recommendations()]);
return { ...p, cost, recs };
} catch (e) {
return { error: e.message };
}
}
console.log(await pageBroken()); // { error: 'recs timeout' }The fix separates required from optional work. Required calls stay in Promise.all so a missing price still fails the page. The optional call starts at the same time but has its failure turned into an empty fallback, so it can never reject the group.
const product = async () => ({ title: 'Lamp' });
const price = async () => 25;
const recommendations = async () => { throw new Error('recs timeout'); };
async function page() {
const recsPromise = recommendations().catch((e) => {
console.log('recs degraded:', e.message);
return [];
});
const [p, cost, recs] = await Promise.all([product(), price(), recsPromise]);
return { ...p, cost, recs };
}
console.log(await page());
// recs degraded: recs timeout
// { title: 'Lamp', cost: 25, recs: [] }Attaching .catch to the optional promise before handing it to all is a compact way to mix policies in one call.
Common mistake
- “
Promise.allstops the other requests when one fails.” It stops waiting; the requests carry on. Pass anAbortSignalif they must actually stop. - “
racereturns the first success.” It returns the first settlement, which may be a rejection.anyreturns the first success. - “
allSettledthrows if something fails.” It never rejects; you must check eachstatus. - Racing a dynamically built list that may be empty.
race([])hangs forever;any([])rejects immediately. - Leaking a timer in a timeout race. The timeout promise’s
setTimeoutkeeps running after the real work wins. Clear it infinally, or useAbortSignal.timeoutwith APIs that accept a signal. - Ignoring later rejections. After
allrejects, other inputs that reject later are already handled internally, so they do not cause unhandled rejection warnings, but their errors are silently dropped. Log them if they matter.
Verify the behavior
This test pins down the settlement rules, including the rejection type of any.
import test from 'node:test';
import assert from 'node:assert/strict';
const ok = (v) => Promise.resolve(v);
const bad = (m) => Promise.reject(new Error(m));
test('all rejects with the first failure', async () => {
await assert.rejects(Promise.all([ok(1), bad('x')]), { message: 'x' });
});
test('allSettled reports every outcome', async () => {
const r = await Promise.allSettled([ok(1), bad('x')]);
assert.deepEqual(r.map((s) => s.status), ['fulfilled', 'rejected']);
});
test('any rejects with AggregateError only when all fail', async () => {
assert.equal(await Promise.any([bad('a'), ok(2)]), 2);
await assert.rejects(Promise.any([bad('a'), bad('b')]), (e) => e instanceof AggregateError && e.errors.length === 2);
});Run node --test combinators.test.mjs; all three tests pass.
Follow-up questions
Can you implement Promise.all yourself? Create a new promise, keep a results array and a counter, attach then to each Promise.resolve(item), store each value at its index, resolve when the counter reaches the length, reject on the first error, and resolve immediately for empty input.
Do the combinators run promises in parallel? They do not run anything; the operations started when you created the promises. The combinator only observes them.
How do you limit concurrency? Combinators do not; use a small worker pool that starts the next task as one finishes.
Why does allSettled exist if you can .catch each promise? It standardises the result shape and makes the intent explicit, so readers know partial failure is expected.
Interview exercise
Write fetchFirstAvailable(urls, fetcher, ms) that returns the first successful response from several mirrors, rejects with a clear message if all mirrors fail, and rejects if nothing succeeds within ms milliseconds. Do not leave a timer running after it settles.
Answer and reasoning
async function fetchFirstAvailable(urls, fetcher, ms) {
let timer;
const deadline = new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error(`no mirror within ${ms}ms`)), ms);
});
try {
const first = Promise.any(urls.map(fetcher)).catch((e) => {
throw new Error(`all ${e.errors.length} mirrors failed`);
});
return await Promise.race([first, deadline]);
} finally {
clearTimeout(timer);
}
}
const fake = (u) => new Promise((res, rej) => setTimeout(() => (u.includes('bad') ? rej(new Error(u)) : res(`ok from ${u}`)), u.includes('slow') ? 50 : 5));
console.log(await fetchFirstAvailable(['bad-1', 'm2'], fake, 30)); // ok from m2
await fetchFirstAvailable(['bad-1', 'bad-2'], fake, 30).catch((e) => console.log(e.message)); // all 2 mirrors failed
await fetchFirstAvailable(['slow-1'], fake, 20).catch((e) => console.log(e.message)); // no mirror within 20msPromise.any expresses “first success wins” and gives an AggregateError when every mirror fails, which is translated into a readable message. Promise.race against a deadline adds the time limit, because the first of “a success” and “the deadline” should win. The finally clears the timer whichever way it settles, so the process is not kept alive by a dangling timeout. A polished answer adds that the losing requests keep running, and suggests passing an AbortController signal to fetcher and aborting it in finally.
Continue learning
Practise further with the JavaScript interview questions and the JavaScript MCQs. Related notes: Promise.race timeouts, async/await in loops and unhandled promise rejections. Official reference: Promise on MDN.