Async Code in Node.js: Callbacks and Promises

Imagine a pizza restaurant during peak hours. Customers keep placing orders: one pizza needs baking, another needs toppings, another needs delivery packing. Now imagine the chef stopping everything until one pizza is fully completed before touching the next order. The restaurant would collapse.
Instead, the kitchen works asynchronously:
dough goes into the oven -> while it bakes, another pizza is prepared -> meanwhile someone packs delivery boxes -> when the oven timer rings, the pizza comes back into focus
That workflow is almost exactly how asynchronous programming works in Node.js. Node.js starts tasks, lets them run in the background, and continues handling other work until the result is ready.
Why Async Code Exists in Node.js
Node.js runs on a single main thread. There is: one call stack, one execution thread, one main flow of execution
That sounds limiting at first. Because what happens if: a database query takes 500ms? a file read takes 2 seconds? an API request hangs temporarily?
If Node.js paused for every slow operation, the entire server would stop responding during that wait.
Imagine an online food delivery app freezing every time it fetched restaurant data. That would be disastrous. So Node.js handles slow operations differently. Instead of waiting, it delegates those tasks to the operating system or libuv and continues executing other code.
You can visualize it like this:
That is the entire philosophy behind non-blocking I/O.
Synchronous vs Asynchronous File Reading
Let’s see the difference in real code.
Synchronous Version
const fs = require("fs");
const content = fs.readFileSync("./notes.txt", "utf-8");
console.log(content);
console.log("Finished reading file");
Here: Node.js stops completely & nothing else executes until the file finishes reading
The thread is blocked.
Asynchronous Version
const fs = require("fs");
fs.readFile("./notes.txt", "utf-8", (err, content) => {
console.log(content);
});
console.log("This runs immediately");
Output:
This runs immediately
[file content appears later]
That surprises many beginners.
But internally, Node.js is doing this:
The thread stays free instead of waiting.
What a Callback Really Is
A callback is simply a function passed into another function so it can run later.
Example:
function processPayment(amount, callback) {
console.log(`Processing ₹${amount} payment...`);
callback();
}
processPayment(500, function () {
console.log("Payment successful");
});
Output:
Processing ₹500 payment...
Payment successful
The callback is basically future behavior.
You are telling JavaScript:
“When the work is done, execute this function.”
Why Node.js Relied So Heavily on Callbacks
Callbacks became the original async pattern because they matched Node.js perfectly.
Node.js constantly deals with: files, APIs, streams and databases. All of these are slower than CPU speed. Callbacks allowed Node.js to remain responsive while waiting for these operations.
Example:
setTimeout(function () {
console.log("Order delivered");
}, 3000);
console.log("Preparing next order");
Output:
Preparing next order
Order delivered
Node.js does not sit idle for 3 seconds. It schedules the task and moves on.
Understanding Error-First Callbacks
Node.js standardized a very important callback pattern.
The first parameter is always: the error
The second parameter is: the successful result
Example:
fs.readFile("./profile.json", "utf-8", (err, data) => {
if (err) {
console.log("Something failed");
return;
}
console.log(data);
});
This became the universal Node.js convention.
Why so ?
Because async operations can fail due to various reasons like file not found, network disconnected, permission denied, database unavailable etc. In that case, error-first callbacks forced developers to handle failures explicitly.
Where Things Became Messy
Callbacks worked well initially. The real problem started when async tasks depended on previous async tasks.
Example:
loginUser(function(user) {
loadCart(user, function(cart) {
processCheckout(cart, function(payment) {
sendReceipt(payment, function() {
console.log("Order completed");
});
});
});
});
Visually:
Every new step pushed the code deeper.
Problems occured are: indentation exploded, readability suffered, debugging became painful, error handling repeated everywhere
This became famous as:
Callback Hell
Or sometimes:
“The Pyramid of Doom”
Because the code shape kept drifting rightward endlessly.
Callbacks themselves were not bad. The problem was deeply nested async dependencies.
Promises Changed the Structure
Promises solved the nesting issue by flattening async flows.
Instead of this:
task1(function() {
task2(function() {
task3(function() {
task4();
});
});
});
Promises allowed this:
task1()
.then(task2)
.then(task3)
.then(task4)
.catch(handleError);
The flow became cleaner, flatter, easier to scan and easier to debug. Most importantly: one .catch() could handle errors from the entire chain.
Understanding Promise States
A Promise represents a future result.
It has 3 states:
Once settled:
it cannot change again
fulfilled promises stay fulfilled
rejected promises stay rejected
That predictability makes async logic easier to reason about.
Creating a Simple Promise
Example:
function orderPizza() {
return new Promise((resolve, reject) => {
const pizzaReady = true;
if (pizzaReady) {
resolve("Pizza delivered");
} else {
reject("Kitchen issue");
}
});
}
orderPizza()
.then((message) => console.log(message))
.catch((error) => console.log(error));
Output:
Pizza delivered
Running Multiple Async Tasks Together
One powerful feature of Promise is parallel execution.
Example:
Promise.all([
fetchUsers(),
fetchProducts(),
fetchOrders()
])
.then((results) => {
console.log(results);
});
Node.js starts all tasks simultaneously instead of waiting one-by-one.
Visualization:
This makes applications much faster.
Why Promises Became Preferred
Promises improved async programming by offering cleaner chaining., centralized error handling , easier composition, Easier readability and compatibility with async/await.
And eventually:
const users = await fetchUsers();
became possible because async/await is built directly on top of promises.
Key Takeaways
Node.js is asynchronous by design.
Slow operations are delegated instead of blocking the thread.
Callbacks were the original async solution.
Error-first callbacks became a Node.js standard.
Deeply nested callbacks created callback hell.
Promises flattened async flows and improved readability.
async/await is built on top of promises.
At its core, Node.js async programming follows one simple philosophy:
“Don’t wait doing nothing while work is happening elsewhere.”





