Async/Await in JavaScript: What It Actually Does
How async/await really works, the sequential-await mistake that makes code 10x slower, and how to handle errors and parallel work correctly.
Table of contents
- The mistake that costs you 10x
- Choosing the right Promise combinator
- Error handling
- Await is not free inside a hot loop
- Top-level await
- Frequently asked questions
- Is async/await faster than promises?
- Can I use await outside an async function?
- Why does my forEach with await not wait?
- How do I add a timeout to a fetch?
- Related reading
- References
async/await is syntax over promises. That sentence is the whole model, and holding onto it explains every piece of behaviour that otherwise looks arbitrary.
An async function always returns a promise. await pauses the function until a promise settles, then resumes with its value. Nothing else changes — the event loop is untouched, and no thread is blocked.
The mistake that costs you 10x#
This is the single most common performance bug in async JavaScript:
// Sequential: takes as long as ALL requests combined
const users = [];
for (const id of userIds) {
users.push(await fetchUser(id));
}Each await waits for the previous request to finish before starting the next. With 10 requests at 200 ms each, that is 2 seconds of wall-clock time for work that could take 200 ms.
The fix is to start everything first, then wait:
// Parallel: takes as long as the SLOWEST request
const users = await Promise.all(userIds.map((id) => fetchUser(id)));map returns an array of already-started promises; Promise.all waits for all of them.
Choosing the right Promise combinator#
await Promise.all(promises); // all succeed, or reject on first failure
await Promise.allSettled(promises); // never rejects; array of {status, value|reason}
await Promise.race(promises); // first to settle, success or failure
await Promise.any(promises); // first to SUCCEED; rejects only if all failPromise.all rejecting on the first failure is usually what you want for "load the page's data". But it means one failed request discards nine successful ones. When partial success is useful — a dashboard with independent widgets — allSettled is the correct choice:
const results = await Promise.allSettled(widgets.map(load));
const loaded = results.filter((r) => r.status === 'fulfilled').map((r) => r.value);Error handling#
A rejected promise inside an async function behaves like a thrown exception, so try/catch works normally:
async function load() {
try {
const response = await fetch('/api/data');
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return await response.json();
} catch (error) {
// Network failure, non-2xx, or malformed JSON all land here.
console.error('Load failed:', error);
throw error; // rethrow unless you can genuinely recover
}
}Two things worth knowing:
fetch does not reject on 404 or 500. It only rejects on network failure. Checking response.ok is mandatory — this is the most common source of "my error handling never runs".
An unawaited rejected promise is an unhandled rejection. In Node 15+ that terminates the process by default.
// Fire-and-forget that crashes the process on failure:
saveAnalytics(event);
// Explicitly acknowledged:
void saveAnalytics(event).catch((error) => console.error(error));Await is not free inside a hot loop#
await on an already-resolved value still yields to the microtask queue. That is fine occasionally and measurable in a tight loop over 100,000 items. If the value is not actually asynchronous, do not await it.
Top-level await#
In ES modules you can await at module scope:
// config.mjs
const config = await loadConfig();
export default config;Useful, with one consequence worth understanding: any module importing this one waits for that promise before executing. A slow top-level await in a widely-imported module delays your whole startup path.
Frequently asked questions#
Is async/await faster than promises?#
Neither is faster — async/await compiles to the same promise machinery. The difference is readability, and readability is why the sequential-loop bug is less common with explicit .then() chains: Promise.all is more obvious there. Understanding the model matters more than the syntax.
Can I use await outside an async function?#
Only at the top level of an ES module. Inside a regular function it is a syntax error. In CommonJS you need an async IIFE: (async () => { ... })().
Why does my forEach with await not wait?#
Because forEach ignores the promise its callback returns. array.forEach(async (x) => await f(x)) starts everything and waits for nothing. Use for...of with await for sequential work, or Promise.all(array.map(...)) for parallel.
How do I add a timeout to a fetch?#
Use AbortSignal.timeout, which is now supported everywhere:
const response = await fetch(url, { signal: AbortSignal.timeout(5000) });Related reading#
- The JavaScript Event Loop, Explained — what "yields to the microtask queue" means
- REST API Design Best Practices — designing endpoints that are cheap to parallelise
- Debugging an API response? Try the JSON Validator.