I4 · Part 2 · Intermediate
Async, properly
Promises, done right — and the sequential-vs-parallel mistake almost everyone makes once.
13On this page
- The mistake: accidental sequential awaiting
- forEach doesn't wait — the async loop trap
- The four combinators
- Promise.all does not cancel the others
- Unhandled rejections
- Predict the order
- AbortController — cancelling something already in flight
- The stale-response race — "latest request wins"
- Promise.withResolvers — resolve/reject without smuggling them out
- Anything with a .then is a thenable — await doesn't require a real Promise
- Linking multiple abort reasons
- Retries
- fetch, past the surface level
Three chapters already did the groundwork this one stands on:
why async exists at all,
the ordering demos, and
promises themselves — the three
states, chaining, .catch, and async/await
as the same thing in different spelling. This chapter assumes all of
that and goes to the layer above it: the mistakes that survive knowing
the syntax.
One-line recap → A promise settles once,
permanently, and every reaction on it runs as a microtask.
.then returns a new promise whose value is whatever your
callback returned. An async function always returns a
promise; await unwraps one and throws on rejection.
The mistake: accidental sequential awaiting
function wait(ms, label) {
return new Promise((resolve) => setTimeout(() => resolve(label), ms));
}
async function sequential() {
const t0 = Date.now();
await wait(50, "a");
await wait(50, "b");
return Date.now() - t0;
}
async function parallel() {
const t0 = Date.now();
await Promise.all([wait(50, "a"), wait(50, "b")]);
return Date.now() - t0;
}
console.log("sequential ~", await sequential(), "ms");
console.log("parallel ~", await parallel(), "ms");
Roughly 100ms, then roughly 50ms. Two
awaits back to back run one after the other — the
second doesn't even start until the first finishes, even
though the two waits have nothing to do with each other. If the work
doesn't depend on the previous result, start both first
(Promise.all, or just call both functions before
awaiting either), and only then await. This exact mistake — awaiting
three independent API calls one by one instead of together — is
a very common, very real source of a slow page.
forEach doesn't wait — the async loop trap
function save(item) {
return new Promise((resolve) => setTimeout(resolve, 50));
}
async function processAll(items) {
items.forEach(async (item) => {
await save(item); // forEach has ALREADY moved to the next item by the time this resolves
});
console.log("done!"); // what happens?
}
processAll([1, 2, 3]);
"done!" logs almost immediately — before any of the
three saves finish. forEach calls its callback three
times and never looks at what any of them return; an
async callback still returns a promise,
forEach just throws every one of those promises away
unread. The fix is a plain for...of loop (sequential,
each await genuinely pauses the outer function) or
await Promise.all(items.map((i) => save(i))) (parallel,
and the outer function actually waits for all three before logging).
The four combinators
| Call | Settles when | Result |
|---|---|---|
Promise.all(promises) | all fulfill, or the first one rejects | array of values, in order — or rejects with that first error |
Promise.allSettled(promises) | every one has settled, success or failure | array of { status, value } or { status, reason } — never rejects itself |
Promise.race(promises) | the very first one settles, fulfilled or rejected | that one result — could be a rejection |
Promise.any(promises) | the first one fulfills — ignores rejections until one succeeds | that fulfilled value, or an AggregateError if all rejected |
function wait(ms, value, fails) {
return new Promise((resolve, reject) =>
setTimeout(() => (fails ? reject(new Error(value)) : resolve(value)), ms)
);
}
const settled = await Promise.allSettled([
wait(10, "ok-1"),
wait(10, "broke", true),
]);
console.log(JSON.stringify(settled)); // what happens?
const winner = await Promise.any([
wait(10, "fails-fast", true),
wait(30, "succeeds-slower"),
]);
console.log(winner); // what happens?
allSettled reports both outcomes without ever throwing —
the standard choice for "run everything, tell me what worked and
what didn't," like a batch upload. any returns
"succeeds-slower": the early rejection doesn't disqualify
the batch, any just keeps waiting until something
actually succeeds — the opposite instinct from race,
which would have surfaced that first rejection immediately.
Promise.all does not cancel the others
const [report, user] = await Promise.all([
fetch("/slow-report"), // keeps running after the line below rejects
Promise.reject(new Error("nope")),
]);
Promise.all rejects the moment any input rejects, but it cannot
stop the rest. Their requests carry on, their side effects still happen, and
their results are quietly discarded. Nothing in the language can cancel a
promise; cancellation is something you build with a signal. Give every task the
same AbortSignal and abort on failure:
const controller = new AbortController();
try {
return await Promise.all(urls.map((u) => fetch(u, { signal: controller.signal })));
} catch (err) {
controller.abort(); // stop the siblings, don't just ignore them
throw err;
}
Promise.all does attach a handler to every input, so a rejection
that arrives after the first one is not reported as unhandled. That
keeps the console quiet, and it is also why a failure can vanish without a
trace.
Unhandled rejections
async function save() { throw new Error("disk full"); }
save(); // no await, no .catch — nobody handles it
A promise that rejects with no handler attached becomes an
unhandled rejection, and the platform decides what that means. In a
browser it fires an unhandledrejection event on
window and logs an error; the page keeps running. In Node.js
since version 15 the default is to treat it as an uncaught exception, which
crashes the process. The usual source is a fire-and-forget async call.
window.addEventListener("unhandledrejection", (e) => {
report(e.reason); // last-resort logging, not error handling
});
process.on("unhandledRejection", (reason) => { // Node
report(reason);
});
save().catch(report); // the real fix: every promise ends in a handler
Predict the order
console.log("1");
setTimeout(() => console.log("2"), 0);
Promise.resolve().then(() => console.log("3"));
(async () => {
console.log("4");
await null;
console.log("5");
})();
queueMicrotask(() => console.log("6"));
console.log("7");
Dry run: the order example above, one row per print
| Prints | Comes from | Why it runs when it does |
|---|---|---|
| "1" | synchronous console.log | runs immediately, top of the script |
| "4" | synchronous, inside the async IIFE | an async function's body runs synchronously up to its first await |
| "7" | synchronous console.log | last line of the script — the call stack is now empty |
| "3" | microtask — .then() on an already-resolved promise | queued 1st, before the async IIFE even ran |
| "5" | microtask — the async function's continuation after await null | queued 2nd |
| "6" | microtask — queueMicrotask | queued 3rd — the microtask queue is FIFO, so it still runs after 3 and 5 |
| "2" | macrotask — the setTimeout callback | runs only once the entire microtask queue is empty, even though it was scheduled first |
Two rules do all the work here: every synchronous line — including an async
function's body up to its first await — finishes before any
microtask runs, and every microtask already queued drains before the event
loop even looks at the next macrotask, regardless of scheduling order.
The output is 1, 4, 7, 3, 5, 6, 2. Step by step: the synchronous code
runs first and prints 1; setTimeout only schedules a task; the
.then callback is queued as a microtask; the async function runs
synchronously up to its first await, printing 4, and its
continuation is queued as a second microtask; queueMicrotask
queues a third; then 7 prints and the call stack empties. Now the microtask
queue drains in order — 3, 5, 6 — and only after it is empty does the
event loop take the timer's task and print 2.
AbortController — cancelling something already in flight
Promises can't be cancelled directly once started — there's no
.cancel(). AbortController is the
standard workaround: a signal that in-flight work can watch for, and
react to by stopping itself.
async function withTimeout(taskFn, ms) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), ms);
try {
return await taskFn(controller.signal);
} finally {
clearTimeout(timer); // clean up even if taskFn finished before the timeout
}
}
const report = await withTimeout(
(signal) => fetch("/api/slow-report", { signal }), // fetch understands AbortSignal natively
5000
);
For the specific "give up after N ms" case, there's a built-in
shortcut that skips the manual timer entirely:
AbortSignal.timeout(5000) returns a signal that aborts
itself on schedule — pass it straight to fetch's
signal option.
The stale-response race — "latest request wins"
A search box that fires a request on every keystroke has a race built in: nothing guarantees responses arrive in the same order the requests left. Type "re", then "rea" a moment later — if the "re" request happens to take longer, its (now-stale) results can land after "rea"'s and overwrite the correct ones on screen.
let latestId = 0;
async function search(query, render) {
const id = ++latestId; // this request's own ticket number
const results = await fetchResults(query);
if (id !== latestId) return; // a newer search started while this was in flight — drop it
render(results);
}
function fetchResults(query) {
const delay = query.length === 2 ? 300 : 30; // simulate "re" being the slow one
return new Promise((resolve) => setTimeout(() => resolve("results for " + query), delay));
}
const rendered = [];
search("re", (r) => rendered.push(r));
search("rea", (r) => rendered.push(r));
await new Promise((r) => setTimeout(r, 400));
console.log(rendered); // what happens?
["results for rea"] — only one result ever renders, even
though both requests actually completed. Each call captures its own
id before awaiting; by the time the slow "re" response
comes back, latestId has already moved on to "rea"'s
ticket, so the stale one silently drops itself instead of overwriting
the screen. AbortController solves the same problem a
different way — cancel the previous request outright instead of
letting it finish and checking afterward — and is the better choice
whenever the request itself is expensive enough that abandoning it
early actually saves real work, not just a render.
Promise.withResolvers — resolve/reject without smuggling them out
function createDeferred() { // the old way
let resolve, reject;
const promise = new Promise((res, rej) => { resolve = res; reject = rej; });
return { promise, resolve, reject };
}
// the same thing, built in:
const { promise, resolve, reject } = Promise.withResolvers();
A "deferred" — a promise whose resolve/reject
are usable from outside its own executor — used to require
the slightly awkward pattern shown above, capturing the callbacks into
outer variables. Promise.withResolvers() is exactly that
pattern, standardized. It's useful anywhere a promise needs to be
settled by something other than its own executor — bridging an
event-based API, or a queue where one function enqueues work and a
separate callback resolves it later.
Anything with a .then is a thenable — await doesn't require a real Promise
const thenable = {
then(resolve) {
setTimeout(() => resolve(42), 10);
},
};
console.log(await thenable); // what happens?
42 — await (and Promise.resolve,
and .then chaining) don't check for
instanceof Promise; they check for a callable
.then method, full stop. That's how libraries which
predate native promises interoperate with await with no
adapter needed — and it's also a real gotcha: an object that merely
happens to have a property named then, for entirely
unrelated reasons, gets treated as a promise by anything that awaits
it.
Linking multiple abort reasons
const userCancel = new AbortController();
const timeout = AbortSignal.timeout(5000);
const signal = AbortSignal.any([userCancel.signal, timeout]); // fires when EITHER does
fetch("/api/report", { signal });
cancelButton.addEventListener("click", () => userCancel.abort());
AbortSignal.any(signals) returns one combined signal that
aborts the moment any of its inputs does, carrying whichever reason
fired first — the standard way to give one request both a timeout and
a user-triggered cancel button without wiring two separate
abort listeners by hand.
Retries
function wait(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function retry(fn, attempts, delay) {
for (let i = 0; i < attempts; i++) {
try {
return await fn();
} catch (error) {
if (i === attempts - 1) throw error; // out of attempts — let the real error surface
await wait(delay);
}
}
}
let tries = 0;
async function flaky() {
tries++;
if (tries < 3) throw new Error("not ready yet");
return "succeeded on attempt " + tries;
}
console.log(await retry(flaky, 5, 10)); // what happens?
"succeeded on attempt 3" — the first two calls throw and
get swallowed (with a delay between attempts), the third succeeds and
its result is what retry finally returns. A real
implementation almost always adds
exponential backoff — delay * 2 ** i instead of a
fixed delay — so retries space out instead of hammering a struggling
server at a constant rate.
fetch, past the surface level
const response = await fetch("/api/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Ana" }),
});
response.ok; // true for 200-299 — still the #1 fetch mistake to forget
response.status; // 201, 404, 500, …
response.headers.get("content-type"); // header access is case-insensitive
The response.ok check is already
covered; forgetting it is still the most common fetch mistake.
CORS, briefly: a browser blocks a script on
a.com from reading a response from b.com
unless b.com's server explicitly opts in with an
Access-Control-Allow-Origin response header. This is
enforced by the browser, not the server. Whether the request
itself reaches the server depends on what kind of request it is, which
is the next box. Either way it's a client-side protection for the
person visiting the page, not a way for a server to protect itself from
being called.
Simple requests and preflighted requests
A simple request is a GET, HEAD or
POST with only browser-safe headers and a
Content-Type of application/x-www-form-urlencoded,
multipart/form-data or text/plain. The browser
sends it straight away with an Origin header. The server
processes it, side effects included, and the browser then withholds the
response unless Access-Control-Allow-Origin permits it.
Anything else is preflighted: PUT, PATCH,
DELETE, a custom header such as Authorization, or
Content-Type: application/json — which is the
POST above. The browser first sends an OPTIONS
request asking permission, and only sends the real one if the answer is yes.
OPTIONS /users // the preflight
Origin: https://a.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type
204 No Content // the server's answer
Access-Control-Allow-Origin: https://a.com
Access-Control-Allow-Methods: POST
Access-Control-Allow-Headers: content-type
POST /users // sent only now
So a blocked JSON POST usually never reaches the server at all,
while a blocked form-style POST usually does. See
the security chapter for the preflight from the
server's side.
⚠ A CORS error is almost never a JS bug
If a request works fine in Postman/curl but fails only from the
browser with a CORS message in the console, the fix is server-side
(adding the right header) — there is no client-side JavaScript
workaround for a server that hasn't opted in.
Interview answer, one sentence
"Independent awaits should run in parallel with Promise.all, but Promise.all cannot cancel its siblings — real cancellation needs an AbortSignal — and every promise must end in a handler, because an unhandled rejection crashes Node and is only logged in a browser."
Before you move on
- Explain why two sequential
awaits take twice as long asPromise.allon independent work, and rewrite a sequential-await function to run in parallel. - Explain why
items.forEach(async ...)doesn't actually wait for anything, and name two ways to fix it. - State what each of
Promise.all,allSettled,race, andanydoes differently the moment one input rejects. - Explain why
Promise.allcan't cancel its sibling requests, and howAbortControllerfixes that. - Trace the "predict the order" snippet's output from memory: sync code, then microtasks, then the timer.
Opens in the editor: write it, run it, and check it against real tests.
All 33 for this level →Finished reading?
0 of 41 chapters read in this series