JavaScript Async and Promises
AI-assistedDrafted with AI assistance and reviewed before publishing; examples are version-pinned. Verify against official docs when in doubt.
JavaScript runs on one thread, yet it has to wait for networks, files and timers. Promises and async/await are how that waiting stays readable instead of turning into nested callbacks. Once the ordering rules are clear, async code stops being guesswork.
The event loop, in one example
console.log("1");
setTimeout(() => console.log("2"), 0);
Promise.resolve().then(() => console.log("3"));
console.log("4");
Output: 1 4 3 2.
- Synchronous code runs to completion first (
1,4). - Then the microtask queue is drained — promise callbacks (
3). - Then the timer callback, a macrotask (
2).
The rule worth memorising: a promise callback always runs before setTimeout(…, 0). Every await resumes as a microtask, so a loop with await inside yields to everything else between iterations.
Timers also have a minimum delay, and setTimeout(fn, 0) is clamped to about 1 ms in browsers; do not build ordering on top of it.
Promise states
const p = fetch("/api/items");
- pending — no result yet.
- fulfilled — succeeded, carries a value.
- rejected — failed, carries a reason.
A promise settles exactly once. Calling resolve a second time does nothing, which is why multiple .then() calls on the same promise always see the same value.
Chaining
fetch("/api/user")
.then((res) => res.json())
.then((user) => user.name)
.then((name) => console.log(name))
.catch((err) => console.error("request failed:", err))
.finally(() => console.log("done"));
thenreturns a new promise; returning a plain value wraps it inPromise.resolve(value).- Returning a promise from
thenmakes the chain wait for it instead of producing a nested promise. catchhandles a failure anywhere before it in the chain.finallyruns on both paths and passes the value through untouched.
Forgetting return inside a then is the most common chaining bug: the next link receives undefined and the request you started is never awaited.
async / await
An async function always returns a promise; await unwraps one.
async function loadUserName(id) {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const user = await res.json();
return user.name;
}
Two legitimate error strategies — pick one per call site rather than mixing both randomly:
// 1) handle it here, add context, maybe fall back
try {
const name = await loadUserName(7);
} catch (err) {
console.error("could not load user 7:", err.message);
}
// 2) let it propagate to a single boundary that knows what to do
async function handler() {
return loadUserName(7); // no try/catch: the caller decides
}
What you should not write is try { await x() } catch {} — an empty catch turns a visible failure into silent wrong behaviour.
Serial versus parallel
// serial: total time is the sum of three round trips
const user = await getUser(1);
const orders = await getOrders(1);
const stats = await getStats(1);
// parallel: total time is the slowest one
const [u, o, s] = await Promise.all([getUser(1), getOrders(1), getStats(1)]);
Independent work should be parallel. Serial is correct only when the next call needs the previous result — or when the calls hit a rate limit.
The four combinators
// all: fails fast on the first rejection
const [user, orders] = await Promise.all([getUser(1), getOrders(1)]);
// allSettled: waits for everything, never rejects
const results = await Promise.allSettled(urls.map((u) => fetch(u)));
const failed = results.filter((r) => r.status === "rejected");
// race: settles with the first promise to settle, success or failure
const withTimeout = Promise.race([
fetch("/api/slow"),
new Promise((_, reject) => setTimeout(() => reject(new Error("timed out")), 3000)),
]);
// any: first success wins; rejects with AggregateError only if all fail
const fastest = await Promise.any([mirrorA(), mirrorB()]);
Promise.all does not cancel the other requests when one fails; they were already sent. Real cancellation needs AbortSignal, covered in the fetch chapter.
Note that Promise.race can leak: the losing request keeps running. AbortSignal.any combined with a per-request controller is the cleanup-friendly version.
Limiting concurrency
Firing 500 requests at once exhausts the connection pool and gets you rate limited. A worker pool is a dozen lines:
async function mapLimit(items, limit, worker) {
const results = new Array(items.length);
let cursor = 0;
async function run() {
while (cursor < items.length) {
const index = cursor++;
results[index] = await worker(items[index], index);
}
}
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, run));
return results;
}
await mapLimit(urls, 5, (url) => fetch(url));
Two important properties: results keep their input order, and a worker never sits idle while work remains. Browsers cap about six connections per host anyway, so a limit beyond that only queues inside the browser.
Wrapping callback APIs
Older libraries still take a callback:
import { readFile } from "node:fs";
function readText(path) {
return new Promise((resolve, reject) => {
readFile(path, "utf8", (err, data) => (err ? reject(err) : resolve(data)));
});
}
Before writing that wrapper, check the standard library: node:fs/promises already exists, and node:util exposes promisify for the rest. Only hand-written wrappers need a decision about multiple callbacks — the first settlement wins, later ones are ignored silently.
Common mistakes
1. async inside forEach
// nothing is awaited, rejections are unhandled
items.forEach(async (item) => {
await save(item);
});
// parallel, awaited
await Promise.all(items.map((item) => save(item)));
// serial, awaited
for (const item of items) {
await save(item);
}
2. A missing await
save(data) returns a promise nobody holds; a rejection surfaces as unhandledRejection far from the cause. await save(data) is the version that waits.
3. A try without await
try {
save(data); // returns a promise and the try block ends immediately
} catch (err) {
// never runs: the rejection happens after the block is gone
}
await save(data) inside the try is what puts the failure on this stack frame. Starting a promise and awaiting it later is fine, as long as something eventually awaits or catches it.
4. Sequential awaits that could be parallel
// slow
const users = [];
for (const id of ids) users.push(await getUser(id));
// fast
const users = await Promise.all(ids.map(getUser));
5. Unhandled rejections
process.on("unhandledRejection", (reason) => {
console.error("unhandled rejection", reason);
process.exitCode = 1;
});
This is a safety net, not a strategy. Node prints a warning today and exits in future defaults; a server that keeps running with a corrupted state is worse than one that restarts.
6. return await
Outside try, return await p and return p behave the same, except the first adds a microtask tick. Inside try, await is mandatory or the catch never fires. Keep return await where it earns its keep and drop it elsewhere.
7. Assuming await is a synchronous pause
The rest of the function becomes a continuation. If another piece of code can change shared state while you are waiting, it will — re-read state after an await instead of trusting a value captured before it.
Summary
- Synchronous → microtasks (promises) → macrotasks (timers); the order is fixed.
- A promise settles once;
thenreturns a new promise andcatchcovers everything before it. async/awaitis syntax over promises: handle errors withtry/catchor propagate to one boundary.- Independent work goes in
Promise.all; partial failure needsallSettled; timeouts useracewith cleanup. - Batch jobs need a concurrency limit, and
forEachplusasyncis the mistake that hides failures.
Next: fetch in practice — status codes it refuses to treat as errors, and cancellation.