Async: Callbacks, Promises, async/await
Almost everything interesting in Node—reading files, querying databases, calling APIs—happens asynchronously. Node has evolved three patterns for managing async work: callbacks, Promises, and async/await. Modern code favors the last, but you’ll meet all three, so it pays to understand the progression.
Callbacks and callback hell
The original pattern passes a function to run when an operation finishes. Node uses the error-first convention: the first argument is an error (or null).
const fs = require("fs");
fs.readFile("a.txt", "utf8", (err, data) => {
if (err) return console.error(err);
console.log(data);
});
Callbacks work, but nesting them produces the infamous “pyramid of doom”:
getUser(id, (err, user) => {
getOrders(user, (err, orders) => {
getInvoice(orders[0], (err, invoice) => {
// deeply nested, hard to read, error handling repeated everywhere
});
});
});
Callback hell isn’t just ugly—it scatters error handling and makes control flow nearly impossible to refactor.
Promises
A Promise represents a value that will exist eventually. It is pending, then either fulfilled or rejected. Chaining .then flattens the pyramid, and a single .catch handles all errors in the chain.
getUser(id)
.then((user) => getOrders(user))
.then((orders) => getInvoice(orders[0]))
.then((invoice) => console.log(invoice))
.catch((err) => console.error("Failed:", err.message));
You can wrap a callback API into a Promise, or use the built-in util.promisify / the fs/promises module.
const fs = require("fs/promises");
fs.readFile("a.txt", "utf8").then(console.log);
async / await
async/await is syntactic sugar over Promises that reads like synchronous code while staying non-blocking. An async function always returns a Promise; await pauses until a Promise settles.
async function run(id) {
const user = await getUser(id);
const orders = await getOrders(user);
const invoice = await getInvoice(orders[0]);
console.log(invoice);
}
Error handling with try/catch
With async/await, errors surface as exceptions, so ordinary try/catch works:
async function loadInvoice(id) {
try {
const user = await getUser(id);
return await getInvoice(user);
} catch (err) {
console.error("Could not load invoice:", err.message);
throw err; // re-throw if the caller should know
} finally {
console.log("done");
}
}
Always
awaitinside thetry. A common bug is returning a Promise without awaiting it—thecatchthen never fires because the function already returned.
Combining Promises
When operations are independent, run them concurrently instead of sequentially.
| Method | Resolves when | Rejects when |
|---|---|---|
Promise.all | All fulfill | Any one rejects (fails fast) |
Promise.allSettled | All settle | Never—returns status for each |
Promise.race | First settles | If the first to settle rejects |
Promise.any | First fulfills | All reject |
const [a, b, c] = await Promise.all([fetchA(), fetchB(), fetchC()]);
Use allSettled when you want every result regardless of individual failures:
const results = await Promise.allSettled([fetchA(), fetchB()]);
results.forEach((r) => {
if (r.status === "fulfilled") console.log(r.value);
else console.error(r.reason);
});
Output:
{ id: 1 }
Error: B is down
Common pitfalls
- Sequential
awaitin a loop when calls are independent—usePromise.allto parallelize. - Forgetting
await, which leaks unhandled rejections and skipstry/catch. awaitinsideforEach, which doesn’t wait; usefor...ofinstead.- Mixing callbacks and Promises in one flow—standardize on async/await.
Best Practices
- Default to async/await; reserve raw
.thenchains for short transformations. - Always handle rejections—attach
.catchor wrapawaitintry/catch. - Parallelize independent work with
Promise.all; useallSettledwhen partial failure is acceptable. - Prefer the
fs/promisesand other promise-based core APIs over callback variants. - Add a global
process.on("unhandledRejection")handler as a safety net, not a substitute for local handling.