JavaScript Async/Await Tutorial for Beginners
A beginner async/await tutorial: async functions, try/catch, fetch with response.ok, Promise.all for parallel work, retries, and the forEach mistake to avoid.
Promise.all, allSettled and race all wait on several promises, but they disagree on failure and on what "done" means. Here are the real differences, with timings from Node.js 24.22.1, plus when to reach for Promise.any.
Promise.all waits for every promise to fulfill and fails fast on the first rejection. Promise.allSettled waits for every promise to finish and always fulfills with an array of {status, value|reason} results. Promise.race settles with whichever promise settles first, success or failure. Use all when one failure should cancel the whole batch, allSettled when you need every outcome, and race for timeouts or "first response wins".
MDN's pages for these methods are the official reference. This guide shows the behaviour with real timings I measured on Node.js 24.22.1 (and confirmed the methods exist on Node 20.20.2 and 18.20.8). If async/await itself is still fuzzy, start with my async/await tutorial, then come back here for the combinators.
Helper used in every timing test:
function delay(ms, value, shouldReject = false) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (shouldReject) reject(new Error(String(value)));
else resolve(value);
}, ms);
});
}In this guide:
| Method | Waits for | On first reject | Result shape | Empty input |
|---|---|---|---|---|
Promise.all | All fulfill (or first reject) | Rejects immediately with that reason | Array of values, same order | Fulfills with [] |
Promise.allSettled | All settle | Keeps waiting | Array of {status, value|reason} | Fulfills with [] |
Promise.race | First to settle | Rejects if that first settle is a reject | That one value or reason | Pending forever |
Promise.any | First fulfill | Ignores rejects until all fail | First value, or AggregateError | AggregateError with empty errors |
I include Promise.any briefly because teams often reach for race when they meant "first success". They are not the same. A quick rule of thumb: if a single failure should fail the request, reach for all. If you are aggregating independent sources and can show a partial UI, reach for allSettled. If you are racing a timer, reach for race. If you are trying mirrors or fallbacks and only need one good answer, reach for any.
All four methods accept thenables and plain values as well as Promises. A plain string is treated as an already-fulfilled value. That is handy in tests, and it is how mixed arrays like [fetch(url), "cached"] still make sense as a list of outcomes.
Promise.all takes an iterable of promises (or thenables / plain values) and fulfills with an array of results in the same order as the input, not the order they finished:
const stamp = () => Date.now();
const t0 = stamp();
const result = await Promise.all([
delay(40, 'a'),
delay(20, 'b'),
delay(60, 'c'),
]);
console.log('result:', result);
console.log('elapsed_ms:', stamp() - t0);result: [ 'a', 'b', 'c' ]
elapsed_ms: 61Elapsed time tracked the slowest promise (~60 ms), and the array stayed ['a','b','c'] even though 'b' finished first. Another check with a slow first slot and a fast second slot still returned ['first-slot', 'second-slot']. MDN's Promise.all page describes this ordering rule.
This is the behaviour that surprises people. The returned promise rejects as soon as any input rejects. Other work is not cancelled; all just stops waiting for it:
const t0 = Date.now();
const flags = { a: false, b: false, c: false };
try {
await Promise.all([
delay(80, 'a').then((v) => { flags.a = true; return v; }),
delay(20, 'boom', true).then((v) => { flags.b = true; return v; }),
delay(100, 'c').then((v) => { flags.c = true; return v; }),
]);
} catch (e) {
console.log('caught:', e.message);
console.log('elapsed_ms:', Date.now() - t0);
await delay(120, 'wait');
console.log('flags_after_wait:', flags);
}caught: boom
elapsed_ms: 21
flags_after_wait: { a: true, b: false, c: true }Rejection arrived at ~20 ms with the fast failure. After a short wait, a and c had still run to completion. all does not abort in-flight work. If you need cancellation, wire AbortController into the underlying tasks yourself.
Use all when the batch is useless without every result: loading config that must all succeed, or a transaction-style fan-out where one failure should fail the request.
One more detail from the run: the rejected branch never set flags.b = true, because the .then on that promise never ran; the rejection skipped it. The other two .then callbacks still ran later. So "fail fast" describes the combined promise, not the sibling tasks. Logging, metrics and lock releases inside those siblings still need their own try/finally or AbortSignal handling.
Wrapping Promise.all in try/catch with async/await is the usual style in application code. The catch receives the first rejection reason, which is often an Error but can be any thrown value.
allSettled waits for every promise to settle and fulfills with a status object for each. Rejections become data, not an exception from allSettled itself:
const t0 = Date.now();
const result = await Promise.allSettled([
delay(40, 'ok'),
delay(20, 'nope', true),
delay(60, 42),
]);
console.log('elapsed_ms:', Date.now() - t0);
for (const r of result) {
if (r.status === 'fulfilled') console.log('fulfilled:', r.value);
else console.log('rejected:', r.reason.message);
}elapsed_ms: 59
fulfilled: ok
rejected: nope
fulfilled: 42Elapsed time matched the slowest input (~60 ms), not the first rejection. Fulfilled entries have value; rejected entries have reason. See MDN's Promise.allSettled reference for the exact shape.
A common API fan-out wants every response it can get:
const urls = ['ok', 'bad', 'ok2'];
const settled = await Promise.allSettled(
urls.map(async (u) => {
if (u === 'bad') throw new Error('404 ' + u);
return 'fetched ' + u;
})
);
const ok = settled.filter((r) => r.status === 'fulfilled').map((r) => r.value);
const bad = settled.filter((r) => r.status === 'rejected').map((r) => r.reason.message);
console.log('ok:', ok);
console.log('bad:', bad);ok: [ 'fetched ok', 'fetched ok2' ]
bad: [ '404 bad' ]Use allSettled for dashboards, health checks, "fetch these ten feeds", or anything where partial success is still useful. If a rejected reason isn't an Error, log it as-is; don't assume .message always exists.
A small helper I use in scripts:
function partitionSettled(settled) {
return {
values: settled.filter((r) => r.status === 'fulfilled').map((r) => r.value),
reasons: settled.filter((r) => r.status === 'rejected').map((r) => r.reason),
};
}Pass the array from allSettled straight in. You get two lists and no thrown exception. That is usually easier to render than unpacking status objects inline in JSX or a template.
race settles with the first input that settles, whether that settle is a fulfill or a reject:
const t0 = Date.now();
const result = await Promise.race([
delay(80, 'slow'),
delay(20, 'fast'),
delay(40, 'mid'),
]);
console.log('result:', result);
console.log('elapsed_ms:', Date.now() - t0);result: fast
elapsed_ms: 20When the fastest promise rejects, the race rejects:
const t0 = Date.now();
try {
await Promise.race([
delay(80, 'slow-ok'),
delay(15, 'fast-fail', true),
delay(40, 'mid-ok'),
]);
} catch (e) {
console.log('caught:', e.message);
console.log('elapsed_ms:', Date.now() - t0);
}caught: fast-fail
elapsed_ms: 14MDN's Promise.race page is the short official description. The classic use is a timeout wrapper: race your work against a rejecting timer.
Here's a tiny timeout helper built on race:
function withTimeout(promise, ms) {
let timer;
const timeout = new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error(`timeout after ${ms}ms`)), ms);
});
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
}
// usage sketch:
// await withTimeout(fetch(url), 2000);Clearing the timer in finally avoids leaking handles when the real work wins. Remember that losing the race does not cancel fetch unless you also pass an AbortSignal. For "wait until the first success, ignore earlier failures", use Promise.any instead of race.
I also use race for "prefer cache, but don't wait forever":
const data = await Promise.race([
readCache(key), // may fulfill fast
delay(50, null).then(() => null) // give up on cache
]);
if (data == null) {
// fall through to network
}If the cache is slow or missing, the timer wins with null and you continue. If the cache is warm, you skip the network. Pairing that with allSettled is overkill; race is the right shape for "first useful signal".
Promise.any fulfills with the first fulfilled value and only rejects when every input rejects, via AggregateError:
const t0 = Date.now();
const result = await Promise.any([
delay(20, 'first-fail', true),
delay(50, 'winner'),
delay(80, 'late'),
]);
console.log('result:', result);
console.log('elapsed_ms:', Date.now() - t0);result: winner
elapsed_ms: 50try {
await Promise.any([
delay(10, 'a', true),
delay(20, 'b', true),
]);
} catch (e) {
console.log('name:', e.name);
console.log('errors:', e.errors.map((x) => x.message));
}name: AggregateError
errors: [ 'a', 'b' ]That is the opposite of race when early failures are noise. MDN documents it under Promise.any. It exists on Node 18+, 20+ and 24 in my checks.
A practical "try these mirrors" sketch:
const mirrors = [
fetch('https://mirror-a.example/file'),
fetch('https://mirror-b.example/file'),
fetch('https://mirror-c.example/file'),
];
const res = await Promise.any(mirrors);
// first 2xx/ok response body wins; early network errors are ignoredI did not hit real mirrors in this batch; the timing tests used delay instead. The control flow matches what you want for CDN fallbacks: ignore early failures, take the first success, and only fail when every mirror fails.
Empty input is where these methods disagree the most:
console.log('all []:', await Promise.all([]));
console.log('allSettled []:', await Promise.allSettled([]));
let raceSettled = false;
const raceP = Promise.race([]);
raceP.then(() => { raceSettled = true; }, () => { raceSettled = true; });
await delay(50, 'tick');
console.log('race [] settled within 50ms?', raceSettled);
try {
await Promise.any([]);
} catch (e) {
console.log('any [] name:', e.name, 'errors length:', e.errors.length);
}all []: []
allSettled []: []
race [] settled within 50ms? false
any [] name: AggregateError errors length: 0all and allSettled treat "nothing to wait for" as done. race([]) never settles. any([]) rejects immediately with an empty AggregateError. Guard empty lists before you race them. Inside an async mapper, reading a property off undefined is a different crash; see Cannot read properties of undefined.
If you catch yourself writing nested try/catch around Promise.all just to keep going after a failure, stop and switch to allSettled. If you catch yourself writing race and then ignoring rejections manually, stop and switch to any. The methods already encode those policies; re-implementing them by hand is how subtle timing bugs creep in.
If you call something that isn't a function while mapping promises, you'll see a different error. My post on TypeError: X is not a function covers that.
All timings above are from Node.js 24.22.1 running test/js/demo.mjs. Quick existence checks:
// Node 20.20.2 and 18.20.8 both printed:
{
all: 'function',
allSettled: 'function',
race: 'function',
any: 'function'
}allSettled and any are widely available in modern Node and current browsers. If you still support very old engines, check your baseline before relying on any. The combinators themselves are part of the language; Node's globals documentation lists Promise among the standard globals.
For day-to-day debugging I keep the four one-liners nearby:
await Promise.all(tasks) for all-or-nothingawait Promise.allSettled(tasks) for a full reportawait Promise.race([work, timeout]) for deadlinesawait Promise.any(mirrors) for first successEverything else is choosing the right one for the failure mode you actually want, then testing it with deliberate delays the way I did above.
Promise.all fulfills with all values or rejects on the first rejection. Promise.allSettled always fulfills with an array of status objects describing each outcome, and never rejects just because an input rejected.
No. It rejects your combined promise early, but the other async work keeps running unless you cancel it yourself (for example with AbortController).
Use race for timeouts or when the first settled result should win, including failures. If you only care about the first success, use Promise.any instead.
any ignores rejections until every input has rejected, then throws AggregateError. race settles on the first rejection or fulfillment, whichever comes first.
It fulfills immediately with an empty array. allSettled([]) does the same. race([]) stays pending. any([]) rejects with AggregateError.
No. Results stay in input order. A faster second promise still lands in index 1.
Filter for status === 'fulfilled' and map to .value. Map rejected entries via .reason for logging.
Yes. await Promise.all(...), await Promise.allSettled(...), and so on. The await just waits on the combined promise the method returns.
Last checked: 6 November 2026. Figures, settings and guidance can change, so confirm anything important with the sources linked above.
0%