Async, properly
Promises, done right — and the sequential-vs-parallel mistake almost everyone makes once.
The event loop itself — call stack, microtask queue, why a 0ms timer
still loses to a promise — already got two full step-through demos
back in the mental model
chapter. If that ordering isn't solid yet, that's the place to
build it; this chapter assumes it and moves straight to the layer on
top: what a Promise actually is, and how to not shoot
yourself in the foot with await.
A promise has exactly three states
| State | Meaning | Can it change again? |
|---|---|---|
| pending | not settled yet | yes — to fulfilled or rejected |
| fulfilled | succeeded, has a value | no — permanent |
| rejected | failed, has a reason | no — permanent |
"Settled" means fulfilled or rejected — either way, done,
forever. A promise can only make that transition once; every
.then()/.catch() attached to it (even
attached late, after it already settled) gets called with that same
final outcome.
fetch("/api/user")
.then((response) => response.json()) // each .then returns a NEW promise
.then((user) => console.log(user.name))
.catch((error) => console.error("failed:", error)) // catches a rejection from ANY step above
.finally(() => hideSpinner()); // runs either way, exactly like try/finally
.catch() at the
end of a chain catches a failure from every step before it — you
don't need one per .then(). That's the real advantage
over callback-style error handling from last chapter: one
handler instead of one check at every level.
async / await is the same promises, different spelling
async function loadUser() {
try {
const response = await fetch("/api/user");
if (!response.ok) throw new Error("Request failed: " + response.status);
return await response.json();
} catch (error) {
console.error("failed:", error);
throw error; // re-throw so the caller still knows it failed
}
}
Two things worth being precise about: an async function
always returns a promise, even if the body has no
await at all and just returns a plain
value — that value gets silently wrapped. And
try/catch around await catches a rejected
awaited promise exactly like a thrown synchronous error — same
syntax, unified handling.
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.
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.
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
}
}
fetch("/api/slow-report", { signal: controller.signal }); // fetch understands AbortSignal natively
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.
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 — already covered, still the #1 fetch mistake to forget
response.status; // 201, 404, 500, …
response.headers.get("content-type"); // header access is case-insensitive
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 — the request
usually still reaches the server and can still have side effects; the
browser just refuses to hand the response back to your
JavaScript. It's a client-side protection for the person visiting the
page, not a way for a server to protect itself from being called.
Opens in the editor — write it, run it, and check it against real tests.