Async/Await Best Practices
Node.js is built on a single-threaded, non-blocking event loop, so the way you write asynchronous code directly determines how fast and how correct your application is. async/await makes asynchronous logic read like synchronous code, but it also makes it easy to introduce silent rejections, accidental serialization, and blocking operations. This page covers the patterns that keep async code clean, fast, and reliable on modern Node.js (20/22 LTS).
Prefer async/await over raw callbacks and chains
Callbacks lead to deep nesting (“callback hell”), and long .then() chains scatter logic across closures. async/await flattens both into linear, readable code where control flow and error handling look familiar. It is the default style for new code in modern Node.
import { readFile } from "node:fs/promises";
async function loadConfig(path) {
const raw = await readFile(path, "utf8");
return JSON.parse(raw);
}
const config = await loadConfig("./config.json");
console.log(config.port);
CommonJS note: top-level
awaitrequires ES modules ("type": "module"inpackage.jsonor a.mjsfile). Inrequire-based files, wrap awaits in anasyncfunction and call it.
Handle every rejection
An unhandled promise rejection in modern Node terminates the process by default. Every await can throw, so wrap fallible operations in try/catch and decide explicitly what to do — retry, fall back, or rethrow with context. Never write an async function and leave its rejection path to chance.
async function fetchUser(id) {
try {
const res = await fetch(`https://api.example.com/users/${id}`);
if (!res.ok) {
throw new Error(`Request failed: ${res.status}`);
}
return await res.json();
} catch (err) {
console.error(`fetchUser(${id}) failed:`, err.message);
throw err; // rethrow so callers can react
}
}
When you call an async function but do not await it (fire-and-forget), you must still attach a .catch(), otherwise the rejection is unhandled.
// Fire-and-forget still needs a rejection handler
logMetric("page_view").catch((err) => console.error("metric failed", err));
As a safety net (not a substitute for local handling), register a global handler so nothing slips through silently.
process.on("unhandledRejection", (reason) => {
console.error("Unhandled rejection:", reason);
process.exitCode = 1;
});
Run independent work concurrently with Promise.all
If two operations do not depend on each other, awaiting them one after another wastes time — the second cannot start until the first resolves. Kick them off together and await them as a group with Promise.all, which resolves when all settle and rejects as soon as any one rejects.
// Slow: serial — total time = userTime + ordersTime
const user = await fetchUser(42);
const orders = await fetchOrders(42);
// Fast: concurrent — total time = max(userTime, ordersTime)
const [user2, orders2] = await Promise.all([
fetchUser(42),
fetchOrders(42),
]);
Choose the combinator that matches your failure semantics:
| Method | Resolves when | Rejects when | Use for |
|---|---|---|---|
Promise.all | all fulfill | first rejection | all-or-nothing work |
Promise.allSettled | all settle | never | collect every result/error |
Promise.race | first settles | first settles (if reject) | timeouts, first-wins |
Promise.any | first fulfills | all reject | fallbacks across sources |
When you need every result regardless of individual failures, prefer allSettled:
const results = await Promise.allSettled([
fetchUser(1),
fetchUser(2),
fetchUser(3),
]);
for (const r of results) {
if (r.status === "fulfilled") console.log("ok", r.value.name);
else console.warn("failed", r.reason.message);
}
Avoid await inside loops when work is parallelizable
await in the body of a for loop runs iterations strictly one at a time. That is correct when each step depends on the previous, but it is needlessly slow for independent items. Map the collection to an array of promises and await them together instead.
const ids = [1, 2, 3, 4, 5];
// Serial: each request waits for the previous one
const slow = [];
for (const id of ids) {
slow.push(await fetchUser(id)); // avoid when independent
}
// Concurrent: all requests in flight at once
const fast = await Promise.all(ids.map((id) => fetchUser(id)));
For large lists, unbounded concurrency can overwhelm a database or remote API. Cap it with a concurrency limit so you stay fast without flooding downstreams.
async function mapLimit(items, limit, fn) {
const results = [];
const executing = new Set();
for (const item of items) {
const p = Promise.resolve(fn(item)).then((r) => {
executing.delete(p);
return r;
});
results.push(p);
executing.add(p);
if (executing.size >= limit) await Promise.race(executing);
}
return Promise.all(results);
}
const users = await mapLimit(ids, 3, fetchUser); // at most 3 at a time
Never block the event loop
The event loop is shared by every request. A long synchronous operation — a tight CPU loop, JSON.parse on a huge payload, or a *Sync file call in a hot path — freezes the entire process, so no other request progresses. Keep synchronous work small, prefer the async (promise) variants of core APIs, and move heavy CPU work off the main thread with a Worker.
import { Worker } from "node:worker_threads";
function runHeavy(data) {
return new Promise((resolve, reject) => {
const worker = new Worker("./heavy-task.js", { workerData: data });
worker.on("message", resolve);
worker.on("error", reject);
worker.on("exit", (code) => {
if (code !== 0) reject(new Error(`Worker exited with code ${code}`));
});
});
}
const result = await runHeavy({ rows: 1_000_000 });
console.log("computed", result);
Output:
computed { sum: 499999500000 }
Remember:
asyncdoes not mean “runs in parallel on another thread.” Awaiting a CPU-bound synchronous function still blocks the loop. Only true async I/O or worker threads yield back to the event loop.
Best practices
- Default to
async/await; reach for raw.then()chains only for short, fire-and-forget composition. - Wrap every awaited call that can fail in
try/catch, and add.catch()to any promise you do not await. - Use
Promise.allfor independent work andPromise.allSettledwhen you must collect every outcome. - Replace
await-in-loop with a mappedPromise.all, and cap concurrency for large collections. - Keep synchronous work tiny; offload CPU-heavy tasks to
worker_threadsso the event loop stays responsive. - Register a global
unhandledRejectionhandler as a last-resort safety net, never as your primary error strategy.