Understanding async/await in JavaScript
Asynchronous code is the part of JavaScript that trips up the most beginners — not because the concept is hard, but because the syntax used to get there changed twice. Here's why async/await won, and how to actually reason about it.
The callback problem
Early JavaScript handled anything asynchronous — reading a file, calling an API — with callback functions. Nesting a few of these together produces what's usually called "callback hell":
getUser(id, (user) => {
getPosts(user.id, (posts) => {
getComments(posts[0].id, (comments) => {
console.log(comments);
});
});
});Each step only knows how to continue by calling the next function, and error handling has to be repeated at every level.
Promises flattened the nesting
A Promise represents a value that will exist eventually. Chaining .then() calls flattens the pyramid above:
getUser(id)
.then((user) => getPosts(user.id))
.then((posts) => getComments(posts[0].id))
.then((comments) => console.log(comments))
.catch((err) => console.error(err));Better, but you're still writing in a style built around callbacks — just chained instead of nested.
async/await reads like synchronous code
await pauses execution inside an async function until a promise resolves, letting you write the same logic as if each step happened in order:
async function loadComments(id) {
try {
const user = await getUser(id);
const posts = await getPosts(user.id);
const comments = await getComments(posts[0].id);
console.log(comments);
} catch (err) {
console.error(err);
}
}Nothing about the underlying behavior changed — it's still promises under the hood, and await only works inside an async function. What changed is that a single try/catch handles errors from every step, and the logic reads top to bottom.
await only pauses the function it's inside — it doesn't make the rest of your program synchronous. Calling an async function without awaiting it will run the rest of your code before that function finishes.
When you still want Promise methods directly
If you need to run several independent async operations at once rather than one after another, reach for Promise.all instead of awaiting each in sequence:
const [user, settings] = await Promise.all([
getUser(id),
getSettings(id),
]);Awaiting these one at a time would double the wait for no reason — they don't depend on each other.
Promise.all has one sharp edge: if any single promise rejects, the whole thing rejects immediately, even if the others would have succeeded. When you want every result regardless of individual failures, Promise.allSettled is the one that waits for all of them and reports each outcome separately:
const results = await Promise.allSettled([getUser(id), getSettings(id)]);
// [{ status: 'fulfilled', value: {...} }, { status: 'rejected', reason: Error }]
for (const result of results) {
if (result.status === 'fulfilled') {
console.log(result.value);
} else {
console.error('One request failed:', result.reason);
}
}Use Promise.all when every result is required for the next step to make sense — one failure means there's nothing useful to do anyway. Use Promise.allSettled when partial results are still useful, like loading a dashboard where one failed widget shouldn't blank out the rest of the page.
The loop footgun: await inside forEach
This looks like it should run each request in sequence and wait for all of them, but it doesn't:
async function loadAll(ids) {
ids.forEach(async (id) => {
const user = await getUser(id);
console.log(user);
});
console.log('done'); // logs before any user has actually loaded
}forEach has no idea the callback it's given is async — it calls the function once per item and moves on immediately, ignoring the promise each call returns. 'done' logs first, and the getUser calls resolve later, whenever they finish, with nothing waiting for them.
A real for...of loop does wait, because await inside it pauses the surrounding function itself:
async function loadAll(ids) {
for (const id of ids) {
const user = await getUser(id);
console.log(user); // each one logs in order, before moving to the next
}
console.log('done'); // logs last, as expected
}If the goal is running them concurrently rather than one at a time, that's back to Promise.all: await Promise.all(ids.map(id => getUser(id))).
Unhandled rejections don't fail loudly
A promise that rejects with nothing to catch it doesn't stop your program the way a thrown error in synchronous code does — it fires asynchronously, often after the code that caused it has already finished running:
async function loadUser(id) {
const user = await getUser(id); // if this rejects and nothing catches it...
return user;
}
loadUser(42); // ...this call site never sees the rejection at allCalling an async function without awaiting or .catch()-ing it means a rejection here becomes an "unhandled promise rejection" — visible in the console as a warning, but nothing in the surrounding code reacts to it. Either await the call inside a try/catch, or attach .catch() directly: loadUser(42).catch((err) => console.error(err));.
FAQ
Do I still need to know Promises if I always use async/await?
Yes — async/await is built entirely on Promises; there's no separate mechanism underneath. Promise.all, Promise.allSettled, and reading a stack trace when something rejects all require understanding what a Promise actually is.
Can I use await at the top level of a script?
In an ES module (including most modern bundler setups), yes — top-level await is supported directly. In a plain <script> tag or a CommonJS file, no — await still requires being inside an async function.
Why does my try/catch around an async function not catch anything?
Usually because the function isn't being awaited at the call site — without await, the async function returns a promise immediately, and the try/catch around the call finishes before that promise ever settles.
Is async/await slower than .then() chains?
No — they compile to functionally the same thing under the hood. The difference is purely how the code reads, not runtime performance.