Async Code in Node.js: Callbacks and Promises
Introduction
When we write code, some operations take time.
Reading a file, making a database query, calling an API, or communicating over a network may not finish immediately.
If JavaScript simply waited for every such operation to finish, the program could become unresponsive.
This is where asynchronous programming comes in.
In this article, we'll understand how Node.js handles asynchronous work using callbacks, why deeply nested callbacks become difficult to manage, and how Promises provide a cleaner way to handle the same problem.
Why Does Async Code Exist in Node.js?
JavaScript executes code on a single main thread.
But being single-threaded does not mean Node.js has to wait for every operation to finish.
For example, consider reading a file:
const fs = require("fs");
const data = fs.readFileSync("large-file.txt", "utf8");
console.log(data);
With the synchronous version, the program waits until the file has been completely read.
But file operations can take time.
Instead, Node.js provides an asynchronous version:
fs.readFile("large-file.txt", "utf8", (err, data) => {
console.log(data);
});
console.log("Program continues...");
Here, Node.js can start the file-reading operation and continue executing other JavaScript code.
Conceptually:
JavaScript
│
▼
Start File Read
│
├──────────────► File System
│ │
│ │ Reading...
▼ │
Continue Execution │
│ │
▼ │
Do Other Work │
│
▼
File Complete
│
▼
Callback
│
▼
Handle Result
Depending on the type of asynchronous operation, Node.js can rely on operating-system mechanisms or libuv's mechanisms to perform the work.
The important idea is:
JavaScript does not have to sit and wait for every I/O operation to finish.
Before Promises: Callbacks
Before Promises became a standard way of handling asynchronous operations, JavaScript commonly used callbacks.
A callback is nothing magical.
It is simply a function passed as an argument to another function with the instruction:
"Do your work, and when you're completely finished, call me back."
For example:
function pickupOrderCB(order, cb) {
setTimeout(() => {
cb(null, {
...order,
status: "picked-up"
});
}, 100);
}
Here, cb is the callback.
When the asynchronous operation finishes, we call:
cb(null, updatedOrder);
So the basic flow is:
Start Operation
│
▼
Do Async Work
│
▼
Work Completed
│
▼
Call Callback
│
▼
Continue
Callback-Based Async Execution
Let's use a restaurant order as an example.
Suppose we have three operations:
Prepare Order
↓
Pick Up Order
↓
Deliver Order
We cannot pick up the order before it has been prepared.
Similarly, we cannot deliver it before it has been picked up.
So each operation depends on the previous one.
We can represent these operations using callbacks:
function prepareOrderCB(dish, cb) {
setTimeout(() => {
cb(null, {
dish,
status: "prepared"
});
}, 100);
}
function pickupOrderCB(order, cb) {
setTimeout(() => {
cb(null, {
...order,
status: "picked-up"
});
}, 100);
}
function deliverOrderCB(order, cb) {
setTimeout(() => {
cb(null, {
...order,
status: "delivered"
});
}, 100);
}
Now we can execute them in order:
prepareOrderCB("Biryani", (err, order) => {
if (err) return console.log(err);
pickupOrderCB(order, (err, order) => {
if (err) return console.log(err);
deliverOrderCB(order, (err, order) => {
if (err) return console.log(err);
console.log(`${order.dish}: ${order.status}`);
});
});
});
The flow looks like this:
prepareOrderCB()
│
▼
callback
│
▼
pickupOrderCB()
│
▼
callback
│
▼
deliverOrderCB()
│
▼
callback
│
▼
Result
For three operations, this is still manageable.
But what happens if we have ten operations?
The Problem With Nested Callbacks
Imagine our application now has to perform:
Prepare
↓
Pickup
↓
Scan
↓
Ship
↓
Track
↓
Deliver
↓
Invoice
With callbacks, every next operation may have to be placed inside the previous callback.
The code starts moving further and further to the right.
This is commonly known as:
Callback Hell
It is also called the Pyramid of Doom.
prepare()
│
└── pickup()
│
└── scan()
│
└── ship()
│
└── track()
│
└── deliver()
The indentation itself isn't the real problem.
The deeper problem is that our asynchronous control flow becomes harder to understand and maintain.
Why Callback Hell Is Difficult
1. Readability
As the number of dependent operations increases, the code becomes deeply nested.
Instead of being able to read the flow from top to bottom, we have to keep moving deeper into callbacks.
2. Repeated Error Handling
With callback-based code, we may have to repeatedly write:
if (err) return console.log(err);
at every level.
For example:
prepareOrderCB("Biryani", (err, order) => {
if (err) return console.log(err);
pickupOrderCB(order, (err, order) => {
if (err) return console.log(err);
deliverOrderCB(order, (err, order) => {
if (err) return console.log(err);
console.log(order);
});
});
});
As the number of operations increases, managing these success and failure paths becomes cumbersome.
3. Inversion of Control
There is another subtle problem.
When we pass our callback to another function, we are trusting that function to call it correctly.
For example:
prepareOrderCB("Biryani", callback);
We are effectively saying:
"I'm giving you this function. Please call it when you're finished."
But what if the other function:
never calls the callback?
calls it twice?
calls it with the wrong data?
calls it at the wrong time?
Now our code has less control over the flow.
This is one reason callback-heavy asynchronous code can become fragile.
Enter the Promise
Promises provide another way to represent asynchronous results.
A useful mental model is:
A Promise is like a receipt for something that will give us a result in the future.
Imagine ordering food.
The restaurant doesn't immediately give you the food.
Instead, you get a receipt representing your order.
The order is still being processed.
Later, you either receive the food or learn that something went wrong.
A Promise works in a similar way.
Start Async Operation
│
▼
Promise
│
▼
Result in Future
Instead of passing a callback directly into every operation, the operation can return a Promise.
Promise States
A Promise has three states:
Pending
/ \
/ \
▼ ▼
Fulfilled Rejected
Success Failure
Pending
The operation is still running.
"Order is being prepared..."
Fulfilled
The operation completed successfully.
"Order prepared successfully."
Rejected
The operation failed.
"Something went wrong."
Once a Promise moves from pending to either fulfilled or rejected, its state is settled and cannot change again.
Creating a Promise
We can create a Promise using:
new Promise(...)
For example:
function prepareOrder(dish) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (!dish) {
reject(new Error("Dish is required"));
return;
}
resolve({
dish,
status: "prepared"
});
}, 100);
});
}
The function passed to new Promise() is called the executor function.
new Promise((resolve, reject) => {
// executor
});
What Are resolve and reject?
A common beginner question is:
"Where did
resolveandrejectcome from? I never passed them."
The Promise constructor provides them automatically.
A simplified mental model is:
function Promise(executor) {
const resolve = () => {};
const reject = () => {};
executor(resolve, reject);
}
This is not the actual implementation, but it helps us understand the idea.
When we write:
new Promise((resolve, reject) => {
// ...
});
JavaScript provides these two functions:
resolve → tells the Promise the operation succeeded
reject → tells the Promise the operation failed
The Executor Runs Immediately
This is an important detail.
Many beginners assume the executor waits until .then() is called.
It does not.
Consider:
console.log("start");
const p = new Promise((resolve) => {
console.log("executor running");
resolve("done");
});
console.log("end");
Output:
start
executor running
end
The executor runs immediately when the Promise is created.
So where does the asynchronous behavior come from?
Usually, the asynchronous operation is started inside the executor.
const p = new Promise((resolve) => {
setTimeout(() => {
resolve("Order prepared");
}, 2000);
});
The executor runs immediately.
But the timer finishes later.
During that time, JavaScript can continue doing other work.
Promise Created
│
▼
Executor Runs Immediately
│
▼
Start Async Operation
│
├──────────────► Timer / I/O / Network
│
▼
JavaScript Continues
│
▼
Operation Completes
│
▼
resolve() / reject()
│
▼
Promise Settles
This distinction is important:
The Promise executor is synchronous, while the asynchronous operation started inside it may complete later.
Handling Promises With .then()
Once we have a Promise, we need a way to react to its result.
That's where:
.then()
comes in.
For example:
prepareOrder("Paneer Tikka")
.then(order => {
console.log(order);
});
.then() registers a handler that runs when the Promise is fulfilled.
Conceptually:
promise.then(onFulfilled, onRejected);
Both handlers are optional.
The fulfilled handler receives the value passed to:
resolve(value);
The rejection handler receives the reason passed to:
reject(error);
In practice, we usually handle rejection using .catch().
Promise Chaining
Now let's rewrite our restaurant workflow.
prepareOrder("Paneer Tikka")
.then(order => pickupOrder(order))
.then(order => deliverOrder(order))
.then(order => {
console.log(`${order.dish}: ${order.status}`);
})
.catch(err => {
console.error(err);
});
Now the flow is much easier to see:
prepareOrder()
│
▼
.then()
│
▼
pickupOrder()
│
▼
.then()
│
▼
deliverOrder()
│
▼
.then()
│
▼
Success
Any failure
│
▼
.catch()
The important part is that each .then() can return another Promise.
For example:
.then(order => pickupOrder(order))
If pickupOrder() returns a Promise, the next .then() waits for that Promise to settle.
That's what allows us to build a chain.
.catch() — Handling Errors
With callbacks, we might repeatedly write:
if (err) return console.log(err);
With Promises, we can handle rejection using:
.catch()
For example:
prepareOrder("Paneer Tikka")
.then(order => pickupOrder(order))
.then(order => deliverOrder(order))
.catch(err => {
console.error(err);
});
Instead of placing error handling inside every nested callback, we can have a common error-handling path for the chain.
prepare
│
▼
pickup
│
▼
deliver
│
▼
success
Any failure
│
▼
.catch()
This is one of the reasons Promise-based code is easier to maintain.
Callback vs Promise
Let's compare the same workflow.
Callback Version
prepareOrderCB("Biryani", (err, order) => {
if (err) return console.error(err);
pickupOrderCB(order, (err, order) => {
if (err) return console.error(err);
deliverOrderCB(order, (err, order) => {
if (err) return console.error(err);
console.log(order);
});
});
});
Promise Version
prepareOrder("Biryani")
.then(order => pickupOrder(order))
.then(order => deliverOrder(order))
.then(order => console.log(order))
.catch(err => console.error(err));
The operations are conceptually the same.
But the structure is different.
CALLBACK
prepare
│
└── pickup
│
└── deliver
│
└── result
PROMISE
prepare
↓
.then()
↓
pickup
↓
.then()
↓
deliver
↓
.then()
↓
result
The Promise version gives us a much flatter representation of the asynchronous flow.
Why Promises Are Useful
Promises aren't just about making code look prettier.
They provide a standard abstraction for representing future asynchronous results.
1. Better Readability
Instead of deeply nesting callbacks, we can chain operations:
prepareOrder()
.then(pickupOrder)
.then(deliverOrder);
The sequence becomes easier to follow.
2. Better Error Propagation
A Promise chain can have a common rejection path:
promise
.then(stepOne)
.then(stepTwo)
.then(stepThree)
.catch(handleError);
This is much cleaner than manually checking errors at every callback level.
3. Easier Composition
Promises can be combined using methods such as:
Promise.all()
Promise.allSettled()
Promise.race()
Promise.any()
For example, if several independent operations must all succeed:
Promise.all([request1, request2, request3])
.then(results => {
console.log(results);
})
.catch(err => {
console.error(err);
});
Promises therefore give us tools for composing multiple asynchronous operations instead of handling every operation in isolation.
4. Clear Separation of Responsibilities
Callbacks often mix together:
start operation
+
handle result
+
handle error
Promises separate these ideas.
An operation can return a Promise:
const orderPromise = prepareOrder("Biryani");
And another part of the code can decide what to do with the result:
orderPromise
.then(order => console.log(order))
.catch(err => console.error(err));
This separation gives us more control over asynchronous flow.
One Important Connection: Promises and the Event Loop
We have already seen the Event Loop in previous Node.js topics.
Promises add one important piece to that picture: the Microtask Queue.
Consider:
console.log("Fury Called");
setTimeout(() => {
console.log("Thor Responded");
}, 0);
Promise.resolve().then(() => {
console.log("Iron Man Responded");
});
console.log("Mission Running");
The output is:
Fury Called
Mission Running
Iron Man Responded
Thor Responded
Why?
The synchronous code runs first:
Fury Called
Mission Running
The Promise handler is scheduled as a microtask.
The timer callback is scheduled for a later task.
After the current synchronous work finishes, pending microtasks are processed before moving on to the next task.
So:
Synchronous Code
│
▼
Microtask Queue
(Promise handlers)
│
▼
Next Task
(Timer callback)
This is why the Promise callback runs before the setTimeout() callback in this example.
Even:
Promise.resolve("done").then(value => {
console.log(value);
});
console.log("end");
produces:
end
done
The Promise is already fulfilled, but the .then() handler still runs asynchronously through the microtask mechanism.
The Journey From Callbacks to Promises
The evolution can be summarized like this:
Asynchronous Operation
│
▼
Callbacks
│
▼
Nested Callbacks
│
▼
Callback Hell
│
▼
Promises
│
├── .then()
├── .catch()
└── Promise Combinators
The goal was not simply to "remove callbacks."
Callbacks are still a fundamental part of JavaScript.
The bigger change was introducing a better abstraction for representing and composing asynchronous results.
Final Takeaway
The main idea behind asynchronous programming in Node.js is simple:
Don't make the JavaScript execution wait unnecessarily for slow I/O operations.
Callbacks gave us a way to say:
"When you're finished, call this function."
But when many operations depend on each other, callbacks can lead to deeply nested code and difficult error handling.
Promises give us another model:
"Give me an object representing the future result, and I'll decide how to handle it."
That gives us:
Cleaner asynchronous flow
Promise chaining
Better error propagation
Easier composition
A standard representation of future results
And this eventually leads to the syntax most developers use today:
Callbacks
↓
Promises
↓
async / await
The underlying asynchronous problem remains the same.
What changes is how clearly and safely we can express the flow of that asynchronous work.
Comments
Join the discussionNo comments yet. Be the first to comment.