Async Code in Node.js: Callbacks & Promises
Understanding non-blocking I/O · the event loop · promise chains

Node.js was built on a single, radical idea: don't wait. Instead of blocking execution while the filesystem reads a file or a server responds to a request, Node.js keeps moving — and comes back when the work is done. This is what makes it blazingly efficient for I/O-heavy applications. But it also means you need to think about code differently.
01 / Why Async Code Exists in Node.js
Traditional, synchronous code executes line by line. When you tell it to read a file, it stops — blocking the entire thread — until that file is fully loaded. In a web server handling thousands of requests per second, this would be catastrophic. Every request would queue up, waiting for the one before it to finish.
Node.js solves this with an event loop — a single-threaded mechanism that delegates slow operations (file reads, network calls, database queries) to the system's kernel and registers a callback to be notified when they complete. While waiting, Node.js is free to process other requests.
The golden rule: In Node.js, expensive I/O never blocks the main thread. Instead, you describe what should happen after the work is done.
02 / Callback-Based Async Execution
The oldest pattern in Node.js is the callback: a function you pass into another function, to be called once the async work completes. Node's built-in fs module uses this pattern extensively.
The File Reading Scenario
Imagine you need to read a configuration file, parse it as JSON, then use those values to fetch a user from a database. With callbacks, each step nests inside the previous one's completion handler.
const fs = require('fs');
// Step 1: Read the file
fs.readFile('config.json', 'utf8', (err, data) => {
if (err) {
console.error('Failed to read file:', err);
return;
}
// Step 2: Parse the JSON
const config = JSON.parse(data);
// Step 3: Fetch user from database
getUser(config.userId, (err, user) => {
if (err) {
console.error('DB error:', err);
return;
}
// Step 4: Log the result
console.log('User:', user.name);
});
});
Callback Execution Flow
Notice the pattern: the callback receives (err, result) as its first two arguments — this is Node's error-first callback convention. You always check for an error before using the result.
03 / Problems with Nested Callbacks
The callback pattern works, but it breaks down at scale. When you have four, five, or six sequential async steps — each depending on the last — your code drifts inexorably to the right. This phenomenon has a name: Callback Hell.
readFile('config.json', (err, config) => {
getUser(config.id, (err, user) => {
getOrders(user.id, (err, orders) => {
getItems(orders[0].id, (err, items) => {
calcTotal(items, (err, total) => {
// We're 5 levels deep. Error handling?
// Code reuse? Good luck.
console.log(total); // 😰
});
});
});
});
});
Beyond the visual horror, callback hell creates real engineering problems: error handling must be duplicated at every level, it's impossible to run steps in parallel elegantly, debugging stack traces become unreadable, and refactoring becomes fragile.
04 / Promise-Based Async Handling
Promises were introduced to solve exactly this. A Promise is an object that represents the eventual completion (or failure) of an async operation. It starts in a pending state, and eventually settles to either fulfilled or rejected.
The Same Code, Rewritten with Promises 👇
// Each step returns a Promise — they chain flat
readFilePromise('config.json')
.then((data) => JSON.parse(data))
.then((config) => getUser(config.userId))
.then((user) => getOrders(user.id))
.then((orders) => getItems(orders[0].id))
.then((items) => calcTotal(items))
.then((total) => console.log(total))
.catch((err) => console.error('Something went wrong:', err));
// One .catch handles ALL errors in the chain above ☝️
The visual improvement is dramatic: the code reads top-to-bottom instead of inside-out. But the real engineering win is that a single .catch() at the end handles every error that bubbles up through the entire chain.
Callbacks | Promises |
|---|---|
Error handling duplicated at every level | One |
Deeply nested, hard to read | Flat, left-aligned chains |
Parallel execution is awkward |
|
Stack traces are confusing | Cleaner, more meaningful traces |
Hard to compose and reuse | Promises are first-class values — store, pass, return |
05 / Benefits of Promises
Running Tasks in Parallel
// Fire all three requests at the same time
const [user, orders, settings] = await Promise.all([
getUser(userId),
getOrders(userId),
getSettings(userId),
]);
// All three complete before we continue
console.log(user.name, orders.length, settings.theme);
async/await — syntactic sugar that makes promise chains look synchronous. Under the hood, it's still promises all the way down. Understanding callbacks and promises first makes async/await immediately intuitive.#Node.js #Async #Promises


