The two patterns that make Node.js non-blocking — and how one evolved from the other.
Every Node.js developer faces this moment: you write what looks like perfectly logical code, and the output comes in the wrong order.
const fs = require("fs");
console.log("Before reading file");
fs.readFile("data.txt", "utf8", (err, content) => {
console.log("File content:", content);
});
console.log("After reading file");
Output:
Before reading file
After reading file
File content: Hello from data.txt!
Wait — "After reading file" printed before the file content? The file read is in the middle of the code, but its result came last?
Welcome to async Node.js. This isn't a bug. This is exactly how Node.js is designed to work — and understanding why is the key to writing effective server-side JavaScript.
Let me walk you through the two async patterns that Node.js is built on: callbacks and Promises.
Why Async Code Exists in Node.js
Node.js runs on a single thread. That one thread has to handle every incoming request, every file read, every database query. If it waited for each slow operation to finish before moving on, one user reading a large file would freeze the server for everyone.
So Node.js uses a different approach: start the operation, move on, handle the result when it arrives.
The File Reading Scenario
Let's compare what happens with sync vs async when three users hit your server:
SYNCHRONOUS (blocking):
────────────────────────────────────────────────────
User A → Read file [████████ 100ms ████████] → Response
User B → Read file [████████] → Response
User C → ...
Each user waits for the previous one. Total: 300ms for all three.
ASYNCHRONOUS (non-blocking):
────────────────────────────────────────────────────
User A → Start read → move on
User B → Start read → move on (all three reads happen simultaneously)
User C → Start read → move on
...100ms later...
All three file reads complete → Send all three responses
Total: ~100ms for all three. Same work, 3x faster.
This is why async code exists. Node.js can't afford to wait.
Callback-Based Async Execution
A callback is a function you pass to an async operation, saying: "Run this function when you're done." It's the original async pattern in Node.js, and you'll see it everywhere in the built-in modules.
Basic File Read with Callback
const fs = require("fs");
fs.readFile("users.json", "utf8", (error, data) => {
if (error) {
console.log("Failed to read file:", error.message);
return;
}
console.log("File data:", data);
});
Let's trace the execution step by step:
Callback Execution Flow:
────────────────────────────────────────────────────
1. fs.readFile() is called
→ Node.js tells the OS: "Read this file"
→ Node.js registers the callback: (error, data) => { ... }
→ Node.js DOES NOT WAIT. Returns immediately.
2. Main thread continues with the next line of code
→ Other requests can be handled
→ Other callbacks can run
3. File read completes (OS signals Node.js)
→ Callback is placed in the task queue
4. Event loop checks: "Is the call stack empty?"
→ YES → Move callback from queue to stack
→ Callback runs: console.log("File data:", data)
Node.js Error-First Convention
Every callback in Node.js core follows the same pattern: the first argument is always the error.
// The pattern:
someAsyncOperation((error, result) => {
if (error) {
// Handle error
return;
}
// Use result
});
This convention is so universal in Node.js that it has a name: error-first callbacks.
// File read
fs.readFile("file.txt", "utf8", (err, data) => { /* ... */ });
// Directory read
fs.readdir("./src", (err, files) => { /* ... */ });
// File write
fs.writeFile("output.txt", content, (err) => { /* ... */ });
// File stats
fs.stat("file.txt", (err, stats) => { /* ... */ });
Always check the error first. Always.
Callback Execution Chain
When async operations depend on each other — each step needing the result of the previous step — callbacks get chained:
const fs = require("fs");
// Step 1: Read the config file
fs.readFile("config.json", "utf8", (err, configData) => {
if (err) return console.log("Config error:", err.message);
const config = JSON.parse(configData);
console.log("1. Config loaded:", config.dbPath);
// Step 2: Use config to read the database file
fs.readFile(config.dbPath, "utf8", (err, dbData) => {
if (err) return console.log("DB error:", err.message);
const users = JSON.parse(dbData);
console.log("2. Users loaded:", users.length);
// Step 3: Write a report based on the data
const report = `Total users: ${users.length}`;
fs.writeFile("report.txt", report, (err) => {
if (err) return console.log("Write error:", err.message);
console.log("3. Report written successfully!");
});
});
});
Callback Chain Visualization:
readFile("config.json")
│
└──→ callback fires with config data
│
└──→ readFile(config.dbPath)
│
└──→ callback fires with db data
│
└──→ writeFile("report.txt")
│
└──→ callback: "Done!"
Each step depends on the previous step's result.
Each step is indented one level deeper.
Problems with Nested Callbacks
That chain above? It's only 3 levels deep and it's already hard to follow. Now imagine 5, 6, or 7 levels. This is the infamous Callback Hell — also called the Pyramid of Doom.
A Real-World Nightmare
Imagine building an API endpoint that needs to:
- Authenticate the user
- Fetch their profile
- Get their order history
- Look up shipping details for the latest order
- Send the response
authenticate(token, (err, userId) => {
if (err) return res.status(401).json({ error: "Unauthorized" });
getProfile(userId, (err, profile) => {
if (err) return res.status(500).json({ error: "Profile not found" });
getOrders(userId, (err, orders) => {
if (err) return res.status(500).json({ error: "Orders not found" });
getShipping(orders[0].id, (err, shipping) => {
if (err) return res.status(500).json({ error: "Shipping not found" });
logActivity(userId, "viewed-order", (err) => {
if (err) console.log("Log failed:", err.message);
res.json({
user: profile.name,
latestOrder: orders[0],
shipping: shipping.address,
});
});
});
});
});
});
Why This Is a Problem
1. READABILITY
→ Code moves RIGHT instead of DOWN
→ 5 levels of indentation — hard to scan
2. ERROR HANDLING
→ Every single level has its own if (err) check
→ Duplicated error patterns — easy to forget one
3. MAINTAINABILITY
→ Adding a step means wrapping everything in another level
→ Removing a step means restructuring the entire chain
4. DEBUGGING
→ Stack traces are confusing with deep nesting
→ Finding which level caused an error is painful
Visual — The Pyramid of Doom:
authenticate(token, (err, userId) => {
·getProfile(userId, (err, profile) => {
··getOrders(userId, (err, orders) => {
···getShipping(orders[0].id, (err, shipping) => {
····logActivity(userId, "viewed", (err) => {
·····res.json({ ... }); // 5 levels deep 😵
····});
···});
··});
·});
});
The code forms a pyramid shape →→→→→
This is the fundamental problem that Promises were invented to solve.
Promise-Based Async Handling
A Promise is an object that represents the eventual result of an async operation — it will either succeed with a value or fail with an error.
The Promise Lifecycle
┌──────────────────────────────────────────────┐
│ │
│ new Promise((resolve, reject) => { │
│ // async work │
│ }) │
│ │
│ ┌─────────┐ │
│ │ PENDING │ │
│ └────┬────┘ │
│ │ │
│ ┌──────────┴──────────┐ │
│ ↓ ↓ │
│ ┌────────────┐ ┌───────────┐ │
│ │ FULFILLED │ │ REJECTED │ │
│ │ resolve() │ │ reject() │ │
│ └─────┬──────┘ └─────┬─────┘ │
│ ↓ ↓ │
│ .then(value) .catch(error) │
│ │
│ .finally() — runs either way │
│ │
└──────────────────────────────────────────────┘
Converting Node.js Callbacks to Promises
Node.js's fs.promises API provides Promise-based versions of all file system operations:
const fs = require("fs").promises;
// Callback version
// fs.readFile("data.txt", "utf8", (err, data) => { ... });
// Promise version
fs.readFile("data.txt", "utf8")
.then((data) => {
console.log("File content:", data);
})
.catch((error) => {
console.log("Error:", error.message);
});
You Can Also Wrap Callbacks Manually
If a library doesn't provide Promises, you can create your own:
function readFilePromise(path) {
return new Promise((resolve, reject) => {
require("fs").readFile(path, "utf8", (err, data) => {
if (err) reject(err);
else resolve(data);
});
});
}
// Now it works with .then()
readFilePromise("data.txt")
.then((data) => console.log(data))
.catch((err) => console.log(err.message));
Node.js also provides a built-in utility for this: util.promisify():
const { promisify } = require("util");
const fs = require("fs");
const readFile = promisify(fs.readFile);
readFile("data.txt", "utf8")
.then((data) => console.log(data))
.catch((err) => console.log(err.message));
The Same Chain — Callbacks vs Promises
Remember the 3-step config → database → report chain? Here's how it looks with Promises:
Callback Version (nested)
fs.readFile("config.json", "utf8", (err, configData) => {
if (err) return console.log(err);
const config = JSON.parse(configData);
fs.readFile(config.dbPath, "utf8", (err, dbData) => {
if (err) return console.log(err);
const users = JSON.parse(dbData);
fs.writeFile("report.txt", `Users: ${users.length}`, (err) => {
if (err) return console.log(err);
console.log("Done!");
});
});
});
Promise Version (flat)
const fs = require("fs").promises;
fs.readFile("config.json", "utf8")
.then((configData) => {
const config = JSON.parse(configData);
return fs.readFile(config.dbPath, "utf8");
})
.then((dbData) => {
const users = JSON.parse(dbData);
return fs.writeFile("report.txt", `Users: ${users.length}`);
})
.then(() => {
console.log("Done!");
})
.catch((error) => {
console.log("Something failed:", error.message);
});
Async/Await Version (cleanest)
const fs = require("fs").promises;
async function generateReport() {
try {
const configData = await fs.readFile("config.json", "utf8");
const config = JSON.parse(configData);
const dbData = await fs.readFile(config.dbPath, "utf8");
const users = JSON.parse(dbData);
await fs.writeFile("report.txt", `Users: ${users.length}`);
console.log("Done!");
} catch (error) {
console.log("Something failed:", error.message);
}
}
generateReport();
Side-by-Side Comparison
CALLBACKS: PROMISES:
───────── ─────────
readFile(cb1) readFile()
·readFile(cb2) .then(data => readFile())
··writeFile(cb3) .then(data => writeFile())
···console.log("Done") .then(() => console.log("Done"))
.catch(err => ...)
Nested → RIGHT Flat → DOWN
Error at every level One catch for all
Hard to add/remove steps Easy to modify
Benefits of Promises
| Benefit | Callbacks | Promises |
|---|---|---|
| Readability | Nested, rightward drift | Flat, top-to-bottom |
| Error handling | Repeated if (err) at every level |
One .catch() for the entire chain |
| Composability | Hard to combine operations |
Promise.all(), Promise.race()
|
| Adding steps | Wrap in another nesting level | Add another .then()
|
| Return values | Must use callbacks to pass data | Each .then() returns a new Promise |
| Debugging | Confusing stack traces | Clearer trace through the chain |
| Modern support | Legacy pattern | Built into Node.js, fs.promises
|
Concurrent Operations with Promise.all()
This is something callbacks can't do cleanly — running independent operations in parallel:
const fs = require("fs").promises;
async function loadAllData() {
// All three reads start simultaneously
const [users, products, settings] = await Promise.all([
fs.readFile("users.json", "utf8"),
fs.readFile("products.json", "utf8"),
fs.readFile("settings.json", "utf8"),
]);
console.log("Users:", JSON.parse(users).length);
console.log("Products:", JSON.parse(products).length);
console.log("Settings loaded:", JSON.parse(settings).theme);
}
loadAllData();
With callbacks, you'd need manual counters to track when all three are done. With Promise.all(), it's one line.
The 5-Level API — Rewritten with Promises
Remember the 5-level callback nightmare? Here it is, flattened:
// Callback Hell version — 5 levels of nesting
authenticate(token, (err, userId) => {
getProfile(userId, (err, profile) => {
getOrders(userId, (err, orders) => {
getShipping(orders[0].id, (err, shipping) => {
logActivity(userId, "viewed-order", (err) => {
res.json({ user: profile.name, shipping: shipping.address });
});
});
});
});
});
// Promise version — completely flat
async function handleRequest(token, res) {
try {
const userId = await authenticate(token);
const profile = await getProfile(userId);
const orders = await getOrders(userId);
const shipping = await getShipping(orders[0].id);
await logActivity(userId, "viewed-order");
res.json({
user: profile.name,
latestOrder: orders[0],
shipping: shipping.address,
});
} catch (error) {
res.status(500).json({ error: error.message });
}
}
Five levels of nesting → zero. Five error checks → one. Same logic, dramatically better code.
Let's Practice: Hands-On Assignment
Part 1: Callback-Based File Operations
const fs = require("fs");
// Write a file, then read it back
fs.writeFile("greeting.txt", "Hello from Node.js!", (err) => {
if (err) return console.log("Write error:", err.message);
console.log("File written!");
fs.readFile("greeting.txt", "utf8", (err, data) => {
if (err) return console.log("Read error:", err.message);
console.log("File says:", data);
});
});
Part 2: Convert to Promises
const fs = require("fs").promises;
async function greetingFile() {
try {
await fs.writeFile("greeting.txt", "Hello from Promises!");
console.log("File written!");
const data = await fs.readFile("greeting.txt", "utf8");
console.log("File says:", data);
} catch (error) {
console.log("Error:", error.message);
}
}
greetingFile();
Part 3: Concurrent Reads with Promise.all()
const fs = require("fs").promises;
async function setup() {
// Create test files
await fs.writeFile("file1.txt", "Data from file 1");
await fs.writeFile("file2.txt", "Data from file 2");
await fs.writeFile("file3.txt", "Data from file 3");
// Read all three concurrently
console.time("Concurrent read");
const [f1, f2, f3] = await Promise.all([
fs.readFile("file1.txt", "utf8"),
fs.readFile("file2.txt", "utf8"),
fs.readFile("file3.txt", "utf8"),
]);
console.timeEnd("Concurrent read");
console.log(f1, "|", f2, "|", f3);
}
setup();
Key Takeaways
- Async code exists in Node.js because the single thread can't afford to wait. Starting operations and handling results later keeps the server responsive.
-
Callbacks are the original async pattern: pass a function, it gets called when the operation finishes. Node.js uses error-first callbacks (
erras the first argument). - Nested callbacks create the Pyramid of Doom — deep indentation, repeated error handling, and unmaintainable code.
-
Promises flatten the nesting with
.then()chains. Async/await makes it even cleaner with synchronous-looking syntax. - Promises add real capabilities callbacks lack:
Promise.all()for concurrent operations, one.catch()for all errors, and composable chains that are easy to modify.
Wrapping Up
Callbacks and Promises aren't competing patterns — they're an evolution. Callbacks came first and they work. But as applications grew more complex and async chains got deeper, the need for something more readable and maintainable became clear. Promises — and the async/await sugar on top — were the answer.
In Node.js, you'll encounter both patterns daily. Legacy code and some npm packages still use callbacks. Modern Node.js APIs (like fs.promises) and new codebases use Promises and async/await. Understanding both means you can work with any codebase.
I'm learning all of this through the ChaiCode Web Dev Cohort 2026 under Hitesh Chaudhary and Piyush Garg. Seeing the callback → Promise → async/await evolution play out with real Node.js file operations made the patterns tangible in a way that abstract examples never could.
Connect with me on LinkedIn or visit PrathamDEV.in. More articles on the way.
Happy coding! 🚀
Written by Pratham Bhardwaj | Web Dev Cohort 2026, ChaiCode
Top comments (0)