Asynchronous code is where a lot of JavaScript developers hit their first real wall. Callbacks nest into a pyramid, promises help but still read awkwardly, and then you meet async/await in JavaScript and suddenly asynchronous code looks almost like the synchronous code you already understand. Almost. There are a few sharp edges, and this guide walks through them.
From promises to async/await
Under the hood, async/await is just nicer syntax over promises. An async function always returns a promise, and await pauses the function until a promise settles. Compare the two styles:
// Promise chain
function getUser() {
return fetch('/api/user')
.then(res => res.json())
.then(user => user.name);
}
// async/await
async function getUser() {
const res = await fetch('/api/user');
const user = await res.json();
return user.name;
}
Same behavior, but the second version reads top to bottom like a story. That readability is the whole point.
Handling errors without losing your mind
With promises you chain .catch(). With async/await you use the humble try/catch you already know:
async function getUser() {
try {
const res = await fetch('/api/user');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} catch (err) {
console.error('Failed to load user:', err);
return null;
}
}
One catch block can guard several await calls, which is far cleaner than sprinkling error handlers through a chain. Note the res.ok check: fetch only rejects on a network failure, so you have to inspect the HTTP status code yourself and turn a 4xx/5xx into a thrown error — a habit that pairs naturally with consuming well-designed APIs.
Don’t await inside a loop (sequential vs parallel)
Here is the single most common async performance trap: awaiting things in a loop that could run in parallel. The cost is brutal and easy to miss — ten sequential one-second requests take ten seconds; fired together with Promise.all they finish in about one.
// Slow: each request waits for the previous one
for (const id of ids) {
results.push(await fetch(`/api/item/${id}`));
}
// Fast: fire them together
const results = await Promise.all(
ids.map(id => fetch(`/api/item/${id}`))
);
If the operations do not depend on each other, Promise.all can turn ten seconds into one. Reaching for await inside a loop is the single most common performance bug I see in async JavaScript.
A few habits worth keeping
Remember that await only works inside an async function (or at the top level of a module). Do not forget the keyword — a missing await gives you a pending promise instead of a value, and the bug can be maddening to spot. And when failure is expected, handle it; an unhandled rejected promise will happily crash your Node process.
Beyond Promise.all: the other combinators
Promise.all has three siblings, and knowing when each fits saves you from reimplementing them badly. Promise.all is all-or-nothing: one rejection and the whole thing throws, which is right when every result is required. Promise.allSettled never throws — it waits for everything and hands you each outcome tagged as fulfilled or rejected:
const results = await Promise.allSettled(urls.map(u => fetch(u)));
const succeeded = results
.filter(r => r.status === 'fulfilled')
.map(r => r.value);
That’s the tool for “fetch from five sources, use whatever came back” — dashboards, link checkers, batch jobs where partial success is still success. Promise.race settles with the first promise to settle either way, the classic building block for timeouts. And Promise.any resolves with the first success, ignoring failures unless everything fails — perfect for querying mirror servers where you want whichever answers first.
The four side by side — the fastest way to pick the right one:
| Combinator | Resolves when | Rejects when | Use case |
|---|---|---|---|
Promise.all |
All inputs fulfill | Any one rejects (fails fast) | You need every result; all-or-nothing |
Promise.allSettled |
All inputs settle | Never — you inspect each outcome | Partial success is fine; you want every outcome |
Promise.race |
The first input settles (fulfill or reject) | If that first-to-settle rejects | Timeouts; first-to-respond wins |
Promise.any |
The first input fulfills | Only if all reject (AggregateError) |
First success from redundant sources |
What await actually does (and why it isn’t blocking)
A mental model worth having: await does not freeze the browser or Node process. When execution hits an await, the function suspends and returns control to the event loop — other code, clicks, and requests keep running. When the promise settles, your function resumes where it left off. So “async code” isn’t about running on another thread (JavaScript still has one main thread); it’s about not wasting that thread while waiting on the network or disk.
This model explains the classic interview puzzle: an async function runs synchronously until its first await, then yields. It also explains why CPU-heavy work — parsing a giant JSON string, crunching numbers — still freezes your UI no matter how many async keywords you sprinkle on it. await yields while waiting; it cannot make computation happen elsewhere. For that you need a Worker.
The fire-and-forget trap
Calling an async function without awaiting it is legal, occasionally intentional, and frequently a silent bug. The function runs, but nothing waits for it and — crucially — nothing catches its errors:
// Looks fine, loses errors
saveDraft(doc); // returns a promise nobody holds
// Explicit fire-and-forget: at least handle failure
saveDraft(doc).catch(err => reportError(err));
In Node, an unhandled rejection can take down the whole process; in browsers it lands as a console error your users never report. The discipline: every promise ends in an await, a .catch(), or a deliberate, commented decision to drop it. Linters can enforce this (no-floating-promises in typescript-eslint) and it’s one of the highest-value lint rules that exists for async-heavy code — the kind of automated guardrail that keeps a codebase clean without relying on willpower.
A related subtlety: return await fn() versus return fn() inside a try block are not equivalent. Without the await, the promise escapes the try before it rejects, and your catch never fires. Inside try/catch, keep the await.
Common async/await mistakes, in one place
Every trap covered above, gathered as a quick pre-flight checklist:
- Awaiting in a loop when the iterations are independent — serializes work that should run in parallel. Use
Promise.allover.map(). - Forgetting
await— you get a pendingPromiseobject instead of the value, and comparisons/logic silently misbehave. - Fire-and-forget — calling an async function without
awaitor.catch(), so its errors vanish (or crash Node). Enableno-floating-promises. return fn()instead ofreturn await fn()insidetry— the promise escapes thetrybefore rejecting, so yourcatchnever fires.- Ignoring rejections — an unhandled rejected promise is a real crash in Node and a silent failure in the browser.
- Expecting
awaitto speed up CPU work — it yields while waiting on I/O; it can’t parallelize computation. Use a Worker for that.
Frequently asked questions
Can I use await outside a function? Yes — top-level await works in ES modules (files loaded with type="module" or .mjs). It’s ideal for initialization code like loading config before the app starts. It doesn’t work in classic scripts or CommonJS.
Does async/await replace promises? No — it consumes promises with nicer syntax. You still create promises (every fetch, every async function call), still use combinators like Promise.all, and still occasionally reach for .then() when it reads better. Understanding promises remains the foundation; async/await is the ergonomics layer.
How do I add a timeout to an await? Race it: await Promise.race([fetchData(), timeout(5000)]) where timeout rejects after the delay. For fetch specifically, prefer the built-in way — AbortSignal.timeout(5000) passed as the signal option — which actually cancels the request instead of merely abandoning it.
Does async/await block the main thread? No. await suspends only your function and hands control back to the event loop, so clicks, timers, and other code keep running while it waits. JavaScript stays single-threaded; async is about not wasting that thread during I/O, not about running on multiple threads.
Why is my await inside a loop so slow? Because each iteration waits for the previous one to finish before starting. If the iterations don’t depend on each other, kick them all off at once with Promise.all(items.map(...)) and await that instead — turning N sequential waits into one parallel wait.
Once async/await in JavaScript clicks, you will wonder how you tolerated callback pyramids. It does not remove the need to understand promises, but it makes writing correct asynchronous code feel natural instead of adversarial. For another everyday async-timing pattern, see debounce and throttle in JavaScript.

