async/await in Depth
The async/await syntax is the modern way to work with asynchronous code in Node.js. It lets you write code that reads sequentially while still running non-blocking under the hood, replacing nested callbacks and long .then() chains with flat, linear logic. Crucially, async/await is not a new concurrency model — it is syntactic sugar over Promises. Understanding how it desugars is the key to using it correctly.
Declaring async functions
Any function prefixed with async returns a Promise. If the function returns a plain value, that value is wrapped in a resolved Promise; if it throws, the Promise rejects. Inside an async function you can use await to pause until a Promise settles, then resume with its resolved value.
async function getUser(id) {
const res = await fetch(`https://api.example.com/users/${id}`);
const user = await res.json();
return user.name;
}
const name = await getUser(42); // top-level await in an ES module
console.log(name);
async works on every function form: declarations, expressions, arrow functions, and object/class methods.
const load = async () => { /* ... */ };
class Repo {
async findAll() { /* ... */ }
}
How it desugars to promises
An await expression does the same thing as .then() — it schedules the continuation as a microtask once the awaited Promise resolves. The two snippets below are equivalent.
// async/await
async function total() {
const a = await getPrice('a');
const b = await getPrice('b');
return a + b;
}
// roughly the same as
function total() {
return getPrice('a').then((a) =>
getPrice('b').then((b) => a + b)
);
}
Because await yields to the event loop, code after an await always runs in a later microtask — never synchronously. await also unwraps non-Promise values harmlessly, so await 5 simply yields 5 (after one microtask tick).
Awaiting a non-thenable still costs a microtask turn. It never blocks the event loop, so other I/O and timers continue to run while an async function is suspended.
Error handling with try/catch
A rejected Promise becomes a thrown exception at the await site, so you handle async failures with ordinary try/catch/finally. This unifies synchronous and asynchronous error handling under one mechanism.
async function fetchJson(url) {
try {
const res = await fetch(url);
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
return await res.json();
} catch (err) {
console.error('Request failed:', err.message);
return null;
} finally {
console.log('Done attempting fetch');
}
}
await fetchJson('https://api.example.com/broken');
Output:
Request failed: HTTP 404
Done attempting fetch
A common pitfall:
return await somethinginsidetrykeeps theawaitinside thetryblock so rejections are caught. A barereturn somethingreturns the Promise and lets the caller deal with the rejection — the localcatchwill not fire.
Awaiting in loops: sequential vs concurrent
This is where async/await is most often misused. Using await inside a for loop runs each operation one after another. That is correct when iterations depend on each other, but wasteful when they are independent.
// Sequential: each request waits for the previous one
async function sequential(ids) {
const results = [];
for (const id of ids) {
results.push(await getUser(id)); // ~N x latency
}
return results;
}
To run independent operations concurrently, start them all first (collect the Promises), then await them together with Promise.all.
// Concurrent: all requests start at once
async function concurrent(ids) {
const promises = ids.map((id) => getUser(id)); // fire them off
return Promise.all(promises); // ~1 x latency
}
For three requests of ~100 ms each, the difference is stark:
| Approach | await placement | Total time | Use when |
|---|---|---|---|
| Sequential | await inside the loop | ~300 ms | each step needs the previous result |
| Concurrent | Promise.all over an array | ~100 ms | iterations are independent |
Array.prototype.forEachdoes not await its async callback — the loop returns immediately and the callbacks run detached. Use afor...ofloop ormap+Promise.allinstead.
Note that await outside an async function is only legal at the top level of an ES module (see top-level await); in CommonJS modules you must wrap it in an async IIFE.
// CommonJS workaround
(async () => {
const data = await fetchJson('https://api.example.com/users/1');
console.log(data);
})();
Returning values and chaining
Because async functions always return a Promise, callers can either await them or use .then(). Returning a Promise from an async function flattens automatically — there is no nested Promise<Promise<T>>.
async function pipeline(id) {
const user = await getUser(id);
return enrich(user); // enrich returns a Promise; it is flattened
}
pipeline(7).then((u) => console.log(u));
Best practices
- Reach for
Promise.all/Promise.allSettledwhen iterations are independent; reserve in-loopawaitfor genuinely sequential work. - Wrap awaited calls in
try/catchand usereturn awaitinsidetryso local error handling actually catches rejections. - Never use
awaitinsideforEach; preferfor...offor sequential andmap+Promise.allfor concurrent. - Always attach a
.catchor surroundingtryto top-level awaits — an unhandled rejection can crash the process. - Remember every
awaityields a microtask; avoid awaiting in hot inner loops where the value is already available synchronously. - In CommonJS, wrap top-level async logic in an async IIFE since
awaitis not allowed at module scope there.