Asynchronous code is the native language of Node.js. Because the runtime hands off I/O and keeps the event loop free, almost everything useful you do — reading a file, querying a database, calling an API — returns its result later, not immediately. How you express "do this, then when it finishes do that" has evolved through three generations in Node, and while modern code lives almost entirely in the third, understanding all three is what lets you read any codebase and avoid the traps each one carries.
This is part of the Node.js runtime series. Here we walk from callbacks through Promises to async/await, plus the EventEmitter pattern that sits alongside them.
Callbacks and the pyramid problem
The original async pattern in Node is the callback: you pass a function that gets called when the operation completes. Node's convention is the "error-first" callback, where the first argument is an error (or null) and the rest is the result.
fs.readFile("config.json", "utf8", (err, data) => {
if (err) return handleError(err)
console.log(data)
})
This works, and for a single operation it is perfectly clear. The trouble starts when operations depend on each other, because each dependent step nests inside the previous callback:
readFile("a.json", (err, a) => {
if (err) return handleError(err)
readFile("b.json", (err, b) => {
if (err) return handleError(err)
readFile("c.json", (err, c) => {
if (err) return handleError(err)
// finally do something with a, b, c
})
})
})
This is "callback hell" — the rightward drift, the repeated error checks, the difficulty of following the flow. Worse than the ugliness is the error handling: every callback has to check and forward its own error, and forgetting one means a silent failure. Callbacks are not wrong, and you will still see them in older APIs and event-based code, but for sequential async logic they scale badly.
Promises make async composable
A Promise is an object representing a value that will exist eventually — it is pending, then either fulfilled with a value or rejected with an error. The shift Promises bring is that async operations become values you can pass around and chain, rather than callbacks you nest.
readFile("a.json")
.then(a => readFile("b.json"))
.then(b => readFile("c.json"))
.then(c => { /* ... */ })
.catch(handleError)
The nesting flattens into a chain, and — the big win — a single .catch at the end handles an error from any step. A rejection skips the remaining .then handlers and jumps to the nearest .catch, so you get centralized error handling for free instead of a check in every callback. Promises also compose in ways callbacks cannot. Promise.all runs several operations concurrently and waits for all of them, which is how you parallelize independent work:
const [a, b, c] = await Promise.all([
readFile("a.json"),
readFile("b.json"),
readFile("c.json"),
])
Running independent operations with Promise.all instead of sequentially is one of the easiest performance wins in Node, since the three reads happen at once rather than one after another. Related combinators — Promise.allSettled when you want every result regardless of failures, Promise.race for the first to finish — cover the other common shapes.
async/await is Promises that read like sync code
async/await is not a replacement for Promises; it is syntax built on top of them. An async function returns a Promise, and await pauses the function until a Promise settles, giving you the value. The payoff is that asynchronous code reads top-to-bottom like ordinary synchronous code, while still being non-blocking underneath.
async function loadConfig() {
try {
const a = await readFile("a.json")
const b = await readFile("b.json")
return merge(a, b)
} catch (err) {
handleError(err)
}
}
This is the same logic as the Promise chain, but the control flow is linear, and errors are handled with an ordinary try/catch — the same construct you use for synchronous errors, which is exactly the unification covered in the error handling post. It is the right default for essentially all new Node code.
The one pitfall to name: await in a loop runs operations one at a time, which is correct when each depends on the last but a silent performance killer when they are independent. If the iterations do not depend on each other, collect the Promises and await Promise.all instead of awaiting inside the loop. And remember the event-loop lesson — await only yields on genuinely async work; awaiting something that does heavy synchronous computation still blocks the thread.
EventEmitter for streams of events
Callbacks, Promises, and async/await all model a single future value. Some things in Node are not one value but a stream of events over time — a server receiving many requests, a file being read in chunks, a socket emitting data repeatedly. For those, Node uses the EventEmitter pattern: an object emits named events, and you register listeners that run each time an event fires.
server.on("request", (req, res) => { /* runs for every request */ })
emitter.on("data", chunk => { /* runs for every chunk */ })
emitter.once("end", () => { /* runs one time */ })
on subscribes to every occurrence, once to a single one. This is a different shape from a Promise — a Promise resolves exactly once, while an emitter fires as often as the event occurs — and it is the foundation that streams are built on. When you find yourself wanting a Promise to resolve multiple times, an EventEmitter (or an async iterator) is the pattern you actually want.
Which to use
For new code, async/await is the default, backed by Promises and their combinators for concurrency. Reach for Promise.all whenever independent operations can run at once. Use EventEmitter when you are modeling repeated events rather than a single result. And recognize callbacks when you meet them in older APIs — you can usually wrap a callback-based function in a Promise (Node's util.promisify does exactly this) to bring it into the modern style. Underneath all of them is the same runtime behavior: the operation is handed off, the thread stays free, and your continuation runs when the result is ready. The patterns are just increasingly pleasant ways to express that one idea.
Originally published at amanksingh.com.
Top comments (0)