The same promises, written differently
- An
asyncfunction always returns a promise, whatever you write inside it. awaitpauses that function until the promise settles. It does not block the thread.- A rejected promise becomes a thrown error, so ordinary
try/catchworks. return xinside an async function resolves its promise withx.
// with then
function load(id) {
return getDoor(id)
.then((door) => getPrice(door))
.then((price) => ({ id, price }))
.catch((err) => ({ id, error: err.message }));
}
// with await
async function load(id) {
try {
const door = await getDoor(id);
const price = await getPrice(door);
return { id, price };
} catch (err) {
return { id, error: err.message };
}
}
Serial when it should be parallel
// 900ms — each waits for the last, for no reason
const user = await getUser(); // 300ms
const doors = await getDoors(); // 300ms
const prices = await getPrices(); // 300ms
// 300ms — they overlap
const [user, doors, prices] = await Promise.all([
getUser(), getDoors(), getPrices(),
]);
// or start them, then await
const userP = getUser();
const doorsP = getDoors();
const user = await userP;
const doors = await doorsP;
If the second call needs the first one's result, sequential is correct. If it does not, it is just slower.
await in a loop
// deliberate: one at a time, in order
for (const id of ids) {
await save(id);
}
// all at once
await Promise.all(ids.map((id) => save(id)));
// THIS DOES NOTHING — forEach ignores the returned promise
ids.forEach(async (id) => { await save(id); });
console.log("done"); // logs immediately, nothing has saved yet
map gives you an array of promises, which is useful with Promise.all. forEach and filter simply discard them — filter(async …) keeps everything, because a promise is truthy.
Errors
Do not wrap every single await in its own try. Group the ones that fail for the same reason and are handled the same way.
// around one call
try {
const data = await load();
} catch (err) {
show(err.message);
}
// around a whole block, when any of it failing means the same thing
try {
const a = await one();
const b = await two(a);
render(b);
} catch (err) {
showError(err);
} finally {
spinner.remove();
}
// or handle it at the call and keep going
const [err, data] = await load().then((d) => [null, d], (e) => [e, null]);
Async functions in the wrong place
The same applies to setTimeout(async …), an async forEach callback, and a top-level call with no .catch(). If nobody is awaiting the promise, nobody sees the error.
// an async event handler: nothing is awaiting it, so a throw is unhandled
button.addEventListener("click", async () => {
await save(); // if this rejects, it is an unhandled rejection
});
// handle it inside
button.addEventListener("click", async () => {
try {
await save();
} catch (err) {
showError(err);
}
});
Top-level await
A module that uses top-level await delays every module that imports it until it settles. That is occasionally what you want for configuration, and a good way to slow a page down otherwise.
// app.js, loaded with type="module"
const config = await fetch("/config.json").then((r) => r.json());
start(config);
Cancelling
An aborted fetch rejects with an AbortError. Treating that as an error shows the reader a message about something they caused on purpose — check the name and return quietly.
const ac = new AbortController();
async function search(q) {
ac.abort(); // cancel the previous one
const controller = new AbortController();
try {
const res = await fetch(`/search?q=${q}`, { signal: controller.signal });
return await res.json();
} catch (err) {
if (err.name === "AbortError") return null; // expected, not a failure
throw err;
}
}
Retrying
Retry only what is worth retrying: a network failure or a 503, not a 400. Retrying a request the server rejected on purpose just makes the same mistake three times.
async function retry(fn, times = 3, wait = 300) {
let lastError;
for (let i = 0; i < times; i++) {
try {
return await fn();
} catch (err) {
lastError = err;
if (i < times - 1) await new Promise((r) => setTimeout(r, wait * 2 ** i));
}
}
throw lastError;
}