Promises and async/await
The single most important topic in modern JavaScript, and the one where generated code most often gets it subtly wrong.
JavaScript is single-threaded. Anything slow — a network request, a file read, a timer — has to happen without blocking, and a Promise is the object representing a value that is not ready yet.
const promise = fetch("/api/users/1"); // a Promise, immediatelyA promise is pending, then either fulfilled with a value or rejected with a reason. Once settled it never changes.
async/await#
async function getUser(id) {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
const user = await getUser("1");await pauses the function until the promise settles, then gives you the value. Two rules:
- An
asyncfunction always returns a promise, even if you return a plain value. awaitonly works inside anasyncfunction, or at the top level of a module.
async function answer() { return 42; }
answer(); // Promise { 42 }, not 42
await answer(); // 42Error handling#
try {
const user = await getUser("1");
console.log(user.email);
} catch (err) {
console.error("failed:", err.message);
} finally {
setLoading(false); // runs either way
}A rejected promise inside await throws, so try/catch works exactly as it does for synchronous code. That is the main reason async/await replaced .then() chains.
Concurrency#
The difference that most affects real performance:
// SEQUENTIAL — three round trips, one after another
const user = await getUser(id);
const orders = await getOrders(id);
const prefs = await getPrefs(id);
// CONCURRENT — all three at once
const [user, orders, prefs] = await Promise.all([
getUser(id), getOrders(id), getPrefs(id),
]);Both are correct. The second takes a third of the time. Look for consecutive await lines with no data dependency — that is the signal.
The same problem in a loop:
// 100 sequential requests
for (const id of ids) {
results.push(await fetchUser(id));
}
// all at once
const results = await Promise.all(ids.map(fetchUser));But do not go unbounded#
await Promise.all(ids.map(fetchUser)); // with 50,000 ids: a 429 stormWhen the array size comes from data rather than from your source code, bound the concurrency:
async function mapLimit(items, limit, fn) {
const out = new Array(items.length);
let i = 0;
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async () => {
while (i < items.length) {
const idx = i++;
out[idx] = await fn(items[idx], idx);
}
}));
return out;
}
const results = await mapLimit(ids, 8, fetchUser);The four combinators#
Promise.all([...]) // all succeed, or reject on the first failure
Promise.allSettled([...]) // never rejects; reports each outcome
Promise.race([...]) // first to settle, success or failure
Promise.any([...]) // first to SUCCEED; rejects only if all failallSettled is the one people under-use. For "fetch these ten things and show me what you got", all is wrong — one failure discards nine successes.
const results = await Promise.allSettled(ids.map(fetchUser));
for (const r of results) {
if (r.status === "fulfilled") console.log(r.value.email);
else console.error(r.reason);
}The floating promise#
The most common bug in generated JavaScript:
async function save(user) {
db.write(user); // not awaited
return { ok: true };
}The function returns before the write happens, any error becomes an unhandled rejection, and since Node 15 an unhandled rejection terminates the process.
Two variants worth recognising:
// forEach ignores the returned promise entirely
items.forEach(async (item) => { await process(item); });
console.log("done"); // prints immediately; nothing is done
// a promise is always truthy
if (isReady()) { } // isReady is async — this is always trueTurn on the rules that catch these:
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/no-misused-promises": "error",They need type information, which you can get on plain JavaScript with a jsconfig.json containing checkJs: true. That one file is worth it for this rule alone.
Creating a promise#
Usually you do not — you get promises from APIs. The exception is wrapping an old callback API:
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
await delay(1000);In Node, util.promisify or the node:fs/promises style APIs already exist for most of the standard library, so reach for those first.
Cancellation#
const controller = new AbortController();
setTimeout(() => controller.abort(), 5000);
try {
const res = await fetch(url, { signal: controller.signal });
} catch (err) {
if (err.name === "AbortError") console.log("cancelled");
else throw err;
}AbortSignal.timeout(5000) does the same in one line. Threading a signal through anything that talks to a network is what makes it cancellable — and on a server, what stops you doing work for a client that has already left.
Exercise#
// Given getUser(id), getOrders(id) and getPrefs(id) (all async):
// 1. Write loadDashboard(id) fetching all three CONCURRENTLY,
// returning { user, orders, prefs }.
// 2. Handle failure so one broken call does not lose the other two.
// 3. Add a 3-second timeout to the whole thing.
console.log("start");Common questions#
.then() or async/await?#
async/await for almost everything — it reads linearly and errors work with ordinary try/catch. .then() still suits short transformations and places where you cannot be in an async function. Do not mix both styles in one function.
Why does my try/catch not catch the error?#
Most likely the promise was not awaited, so the error happened after your try block finished. Errors thrown inside a callback that runs later — setTimeout, an event handler — also cannot be caught by the surrounding try, because that stack is gone by then.
Is await in a loop always wrong?#
No. It is right when each iteration depends on the previous one, or when you are deliberately rate-limiting. It is wrong when the iterations are independent, which is the common case — and that is why no-await-in-loop is best set as a warning rather than an error.
Get the JavaScript agent pack
A battle-tested AGENTS.md, the review checklist, and the failure-mode cheat sheet for JavaScript. One email, then occasional updates when the tooling shifts. No course pitch.
AGENTS.md now — no email needed.