JavaScript async/await Explained: How It Really Works (and How to Use It Well)
async/await is the syntax that finally made asynchronous JavaScript read like synchronous JavaScript. It's not a new mechanism — it's syntactic sugar over Promises (Lesson 5.2). But it's such a big readability improvement that it changed how every modern JS codebase is structured.
This lesson covers exactly what async and await do under the hood, the patterns for sequential vs parallel work, error handling with try/catch, the top-level await feature, common bugs to avoid, and the rules of thumb that experienced developers reach for.
What async does #
Adding async to a function does exactly two things:
- The function always returns a Promise.
- The function body is allowed to use
await.
async function getUser() {
return { name: 'Ada' };
}
const result = getUser();
// result is Promise<{ name: 'Ada' }>, not the object itself
result.then(user => console.log(user));
Even if you return a plain value, calling async function wraps it in a Promise. If you throw, the Promise rejects.
async function failing() {
throw new Error('oops');
}
failing().catch(e => console.log(e.message)); // 'oops'
What await does #
await pauses the function until a Promise settles, then returns the resolved value (or throws the rejection).
async function fetchUser(id) {
const res = await fetch(`/api/users/${id}`);
const user = await res.json();
return user;
}
Reads top-to-bottom like synchronous code, even though there's a network call in the middle.
Three mental model points:
awaitonly works insideasyncfunctions (or at module top-level — see below).- The function suspends, but the rest of the program continues. Other code can run while
awaitis waiting. - When the Promise resolves, the function resumes from the next line.
It's not a new threading model. It's a smarter way to write "do this, then that" without .then(...) chains.
Comparing the three styles #
Three functionally identical implementations of the same logic:
// Callback hell (Lesson 5.1)
fetchUser(id, (err, user) => {
if (err) return console.error(err);
fetchPosts(user.id, (err, posts) => {
if (err) return console.error(err);
console.log(posts.length);
});
});
// Promise chain
fetchUser(id)
.then(user => fetchPosts(user.id))
.then(posts => console.log(posts.length))
.catch(err => console.error(err));
// async/await
try {
const user = await fetchUser(id);
const posts = await fetchPosts(user.id);
console.log(posts.length);
} catch (err) {
console.error(err);
}
The async/await version reads sequentially. Errors flow through try/catch like sync code. No callback nesting, no .then chain to untangle.
Error handling: try/catch #
A rejected Promise becomes a thrown exception inside an async function:
async function safeGet(url) {
try {
const res = await fetch(url);
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
return await res.json();
} catch (err) {
console.error('Failed:', err);
return null;
}
}
The try block can wrap multiple awaits, each of which can throw. One catch handles all of them — same as synchronous code.
Re-throw to propagate #
Don't swallow errors silently. Either handle them, or re-throw so callers can:
async function process() {
try {
await loadConfig();
} catch (err) {
if (err.code === 'NOT_FOUND') return DEFAULT_CONFIG;
throw err; // unexpected — let it propagate
}
}
The default behavior is correct #
If an async function throws and no caller catches it, you get a runtime UnhandledPromiseRejection. In Node 15+ this is fatal by default (process exits). In the browser, it logs to console. Either way, you'll know — silent failures are no longer possible without explicit .catch.
Sequential vs parallel #
The single most-misused pattern in async/await: doing things sequentially that could be parallel.
// SEQUENTIAL — each await blocks the next
async function loadEverything() {
const user = await fetchUser();
const posts = await fetchPosts();
const comments = await fetchComments();
return { user, posts, comments };
}
// Total time: user-time + posts-time + comments-time
If these three calls don't depend on each other, run them in parallel:
// PARALLEL — all three fire simultaneously
async function loadEverything() {
const [user, posts, comments] = await Promise.all([
fetchUser(),
fetchPosts(),
fetchComments(),
]);
return { user, posts, comments };
}
// Total time: max(user-time, posts-time, comments-time)
Promise.all([p1, p2, p3]) returns a single Promise that resolves with an array of results — or rejects on the first failure.
When you don't want to fail fast #
Promise.allSettled waits for every promise regardless of success:
const results = await Promise.allSettled([
fetchUser(),
fetchPosts(),
fetchComments(),
]);
// results: [
// { status: 'fulfilled', value: ... },
// { status: 'rejected', reason: ... },
// { status: 'fulfilled', value: ... },
// ]
Use when partial success is meaningful — "load everything you can, report what failed."
Promise.race and Promise.any #
await Promise.race([fetchSlow(), timeout(5000)]); // first to settle (resolve OR reject)
await Promise.any([primary(), backup()]); // first to RESOLVE; rejects only if all fail
Use race for timeouts (one of them is the timeout promise that rejects), any for redundancy.
Awaiting in loops #
The sequential trap shows up here, too:
// SEQUENTIAL — one at a time, slow
async function processAll(ids) {
const results = [];
for (const id of ids) {
const data = await fetchById(id);
results.push(data);
}
return results;
}
If the operations are independent, parallelize:
// PARALLEL — all fire at once
async function processAll(ids) {
return Promise.all(ids.map(id => fetchById(id)));
}
When you must process sequentially (each step depends on the previous, or you're rate-limiting), the for...of + await form is correct.
forEach doesn't await #
A common bug:
async function broken(ids) {
ids.forEach(async (id) => {
await fetchById(id); // each iteration's promise is dropped
});
console.log('done'); // logs immediately — the awaits aren't actually awaited
}
forEach doesn't understand Promises. Use for...of or Promise.all(...map(...)).
Top-level await #
In ES modules (Lesson 6.1), you can await at the module's top level:
// module.js
const config = await loadConfig();
export const api = createClient(config);
No wrapping async function main() {} needed. The module's evaluation waits for the await before its exports are available to importers.
Useful for one-shot setup. Doesn't work in CommonJS or inside non-async functions.
Common patterns #
Retry with backoff #
async function withRetry(fn, retries = 3) {
for (let attempt = 0; attempt < retries; attempt++) {
try {
return await fn();
} catch (err) {
if (attempt === retries - 1) throw err;
await new Promise(r => setTimeout(r, 2 ** attempt * 500));
}
}
}
Timeout via Promise.race #
function timeout(ms) {
return new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), ms));
}
await Promise.race([fetch(url), timeout(5000)]);
Or the modern way:
await fetch(url, { signal: AbortSignal.timeout(5000) });
Awaiting a delay #
await new Promise(r => setTimeout(r, 1000)); // sleep 1 sec
Often wrapped:
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
await sleep(1000);
What await does NOT do #
- It does not block the thread. Other code can run while waiting. JavaScript is still single-threaded.
- It does not magically make sync APIs async.
awaitonly meaningfully waits on Promises.await 42is just42(well, on the next microtask, but the effect is identical). - It does not change the semantics of the wrapped Promise. A Promise that resolves in 100 ms is still done in 100 ms.
A summary #
async functionalways returns a Promise. Even when you return a plain value.awaitpauses the function until the Promise settles. Other code runs in the meantime.- Errors propagate via
try/catch. Rejections become thrown exceptions. Promise.allfor parallel independent work.awaitin afor...ofloop only when each step depends on the previous.- Top-level
awaitworks in ES modules. forEachdoesn't await. Usefor...ofor map+Promise.all.
Master these and you'll write async JavaScript that reads as cleanly as the synchronous version.
What's next #
That completes Module 5. The remaining Module 6 lesson (ES modules) ties everything together: top-level await, native imports, and the modern module system every modern runtime uses.
Try it yourself #
The sequential-vs-parallel timing difference is the most useful one to feel:
const wait = (ms) => new Promise(r => setTimeout(r, ms));
// A
const a = await wait(1000);
const b = await wait(1000);
// B
await Promise.all([wait(1000), wait(1000)]);js_sandboxVersion A: ~2000 ms. The two awaits are sequential — wait 1 second, then wait another second.Version B: ~1000 ms. Both
wait calls start at the same instant; Promise.all resolves when both finish.That’s why parallelizing independent awaits matters in real APIs — the same logic can take half the wall-clock time.
One simple change — wrapping with Promise.all instead of stacking awaits — can cut your latency in half wherever the work is independent. Get this instinct right and async code stops being mysterious.
Up next in JavaScript
More from this topic
Enjoyed this article?
Get new JavaScript tutorials delivered. No spam — just code-first articles when they ship.


