Async Code in Node.js: Callbacks and Promises

One of the most important concepts in Node.js is asynchronous programming. If you’ve ever worked with file handling, APIs, or databases in Node.js, you’ve already used async code, even if you didn’t fully realize it.
At first, async programming can feel confusing because the code doesn’t always execute from top to bottom in a simple sequence. But once you understand why async code exists, callbacks and promises start making a lot more sense.
In this article, we’ll understand how asynchronous code works in Node.js, why callbacks were introduced, the problems they created, and how promises made async code cleaner and easier to manage.
Why Async Code Exists in Node.js
Node.js was designed to handle multiple tasks efficiently without blocking the main thread. Most backend operations take time to complete, such as:
reading files
querying databases
calling APIs
communicating over networks
If Node.js waited for every operation to finish before moving to the next task, the server would become slow and unresponsive.
Imagine a restaurant chef preparing food. If the chef stopped everything and waited beside the oven for one pizza to bake before taking new orders, the restaurant would quickly become chaotic.
Instead, a smart chef puts the pizza in the oven and continues preparing other dishes while waiting.
That’s exactly how Node.js handles asynchronous operations.
Starting with a File Reading Scenario
Let’s take a simple example. Imagine you want to read a large file from the system.
Here’s the synchronous version:
const fs = require("fs");
const data = fs.readFileSync("notes.txt", "utf8");
console.log(data);
console.log("Finished");
This code blocks execution. Node.js waits until the file is fully read before moving forward.
Now look at the asynchronous version :
const fs = require("fs");
fs.readFile("notes.txt", "utf8", (err, data) => {
console.log(data);
});
console.log("Finished");
This time, Node.js starts reading the file and immediately continues executing the next line. That’s why "Finished" gets printed before the file content appears.
Instead of waiting, Node.js keeps moving.
Callback-Based Async Execution
The function passed inside readFile() is called a callback.
A callback is simply a function that runs later, after an operation completes.
In the previous example:
(err, data) => {
console.log(data);
}
this function does not execute immediately. Node.js stores it temporarily and runs it once the file reading operation finishes.
Here’s the flow step-by-step:
Start File Read
↓
Node.js Continues Execution
↓
File Reading Completes
↓
Callback Function Executes
Callbacks became the foundation of asynchronous programming in early Node.js applications because they allowed the event loop to stay free while slow operations happened in the background.
Problems with Nested Callbacks
Callbacks solved the blocking problem, but they introduced another issue: deeply nested code.
Imagine a real-world situation where you need to:
Read a file
Fetch data from an API
Save something into a database
Send a response
Using callbacks, the code can quickly become messy.
readFile("user.txt", (err, fileData) => {
fetchUserData(fileData, (err, userData) => {
saveToDatabase(userData, (err, result) => {
sendResponse(result, (err) => {
console.log("Done");
});
});
});
});
Notice how the code keeps moving deeper and deeper toward the right side.
This problem became famously known as:
“Callback Hell”
or sometimes:
“Pyramid of Doom”
The biggest issue wasn’t just appearance. Nested callbacks made code:
harder to read
difficult to debug
painful to maintain
harder to handle errors properly
As applications grew larger, developers needed a cleaner way to manage async operations.
Promise-Based Async Handling
To solve callback problems, JavaScript introduced Promises.
A Promise represents a value that may become available in the future.
Think of it like ordering food online.
When you place the order:
you don’t receive the food immediately
you receive a promise that the food will arrive later
The promise can eventually be:
fulfilled → operation succeeded
rejected → operation failed
const fs = require("fs").promises;
fs.readFile("notes.txt", "utf8")
.then((data) => {
console.log(data);
})
.catch((err) => {
console.log(err);
});
Now the flow feels much cleaner.
Instead of nesting callbacks inside callbacks, promises allow operations to be chained in a more readable way.
Promise Lifecycle
A Promise usually goes through three states:
Pending
↓
Fulfilled → Success
OR
Rejected → Error
When the async operation starts, the promise is in a pending state.
After completion:
it becomes fulfilled if successful
rejected if something fails
Comparing Callback vs Promise Readability
Let’s compare both approaches side-by-side.
Callback Style
getUser(id, (err, user) => {
getPosts(user, (err, posts) => {
getComments(posts, (err, comments) => {
console.log(comments);
});
});
});
The code becomes deeply nested very quickly.
Promise Style
getUser(id)
.then((user) => getPosts(user))
.then((posts) => getComments(posts))
.then((comments) => console.log(comments))
.catch((err) => console.log(err));
This version is much easier to read because the operations flow linearly from top to bottom.
That readability is one of the biggest reasons promises became so popular in modern JavaScript and Node.js development.
Benefits of Promises
Promises improved asynchronous programming in several important ways.
They made code:
cleaner and easier to read
easier to maintain
simpler to debug
better for error handling
easier to chain async operations together
Most modern Node.js APIs now support promises directly because they work much better for large-scale applications.
Promises also became the foundation for async/await, which made asynchronous code look even more synchronous and readable.
Real-World Example
Imagine an e-commerce application.
When a user opens their dashboard, the server may need to:
fetch user details
load recent orders
retrieve notifications
fetch payment information
All of these operations involve asynchronous tasks like database queries and API calls.
Without async programming, every user request would block the server while waiting for each operation to complete.
With callbacks and promises, Node.js can handle these tasks efficiently while still serving other users at the same time.
That’s one of the reasons Node.js performs so well for modern web applications.
Conclusion
Async programming is at the heart of Node.js.
Callbacks were the first major solution for handling asynchronous operations without blocking the event loop. They worked well, but deeply nested callbacks quickly became difficult to manage.
Promises improved this experience by making async code cleaner, more readable, and easier to maintain.
The important thing to remember is this:
Async code allows Node.js to stay productive while waiting for slow operations to complete.
And that single idea is what makes Node.js capable of handling massive numbers of requests efficiently.




