async / await
async and await are syntactic sugar over promises. An async function always returns a promise, and await lets you pause inside it until a promise settles — making asynchronous code read like the synchronous version. You still get the same event-loop behaviour and the same promises; you just stop wiring up .then chains by hand.
The basics
Put async in front of any function and it becomes asynchronous. Inside, await someExpression waits for the expression's promise (or wraps a plain value in one) and returns its fulfilment value. If the promise rejects, the rejection is thrown like a regular exception.
async function greet() {
return "hello"; // automatically wrapped in Promise.resolve
}
greet().then((v) => console.log(v)); // "hello"
async function loadUser(id) {
const response = await fetch(`/api/user/${id}`);
const user = await response.json();
return user;
}The same function written with .then is longer and noisier:
Plain promise version
function loadUser(id) {
return fetch(`/api/user/${id}`).then((response) => response.json());
}The mental model
Two ideas explain almost every async bug:
Calling an async function returns a promise immediately, even before any
awaitinside it runs. The function only pauses internally at eachawait.await xis roughlyPromise.resolve(x).then(...): it schedules the rest of the function as a microtask and yields control. Other code keeps running in the meantime.
async function f() {
console.log("a");
await 0; // splits the function into two microtasks
console.log("c");
}
console.log("1");
f();
console.log("2");1 a 2 c
After await 0, the rest of f is parked as a microtask. The synchronous "2" runs before "c".
try / catch around await
Because rejected promises become thrown errors, you handle async failures with the same try / catch you already know. No special .catch syntax is needed.
async function loadProfile(id) {
try {
const response = await fetch(`/api/user/${id}`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return await response.json();
} catch (err) {
console.error("failed to load", id, err);
return null;
}
}Running things in parallel
A common mistake is to await independent tasks in sequence and pay for their times added together:
Slow — sequential
async function loadDashboard() {
const user = await fetch("/api/user").then((r) => r.json());
const posts = await fetch("/api/posts").then((r) => r.json());
const tags = await fetch("/api/tags").then((r) => r.json());
return { user, posts, tags };
}Kick the work off first, then await the resulting promises together with Promise.all:
Fast — parallel
async function loadDashboard() {
const [user, posts, tags] = await Promise.all([
fetch("/api/user").then((r) => r.json()),
fetch("/api/posts").then((r) => r.json()),
fetch("/api/tags").then((r) => r.json()),
]);
return { user, posts, tags };
}Sequence only what truly depends on the previous step. Everything else belongs in a combinator.
Loops and await
for and for...of cooperate with await — each iteration waits before starting the next. forEach does not: the callback is async, but forEach does not await it, so the loop finishes "too early".
// Sequential — one at a time.
for (const id of ids) {
await processOne(id);
}
// Parallel — all at once.
await Promise.all(ids.map(processOne));
// BUG: looks sequential, isn't.
ids.forEach(async (id) => {
await processOne(id); // forEach throws away the returned promise
});Common bugs
Forgotten
await.const data = fetch(url)assigns a pending promise todata. Readingdata.titlelater isundefined.Awaiting too late. Storing a promise in a variable is fine — just
awaitit before you use the value.Mixing
.thenandawaitcarelessly. Pick one style per function; the chains can be hard to read otherwise.Top-level await outside modules. It only works in ES modules. In a classic
<script>you need anasyncwrapper.Swallowed rejections. A
for...ofloop withouttry/catchpropagates the rejection, but aforEachcallback hides it because the returned promise is dropped.
Forgotten await
async function bad() {
const user = fetch("/me").then((r) => r.json());
console.log(user.name); // "Cannot read properties of undefined (reading 'name')"
}
async function good() {
const user = await fetch("/me").then((r) => r.json());
console.log(user.name);
}async functions are still promises
An async function is a normal promise-returning function — you can call .then/.catch on it, pass it to Promise.all, or await it from another async function. Throwing inside the function becomes a rejected promise outside it.
async function fail() {
throw new Error("nope");
}
fail().catch((err) => console.log(err.message)); // "nope"