Async Code in Node.js: Callbacks and Promises

Learning web development in public. Writing simple, real-world explanations about web development concepts. Helping beginners understand why things work, not just how.
In this article, we’ll understand one of the most important concepts in Node.js — asynchronous code.
What we are going to learn
Why async code exists in Node.js
Callback-based async execution
Problems with nested callbacks
Promise-based async handling
Benefits of promises
What is Async Code and Why It Exists
Let’s start with a basic question:
Why doesn’t Node.js execute everything line by line like traditional programs?
Because Node.js is single-threaded.
This means:
It can run only one task at a time
But it still needs to handle multiple users, requests, file operations, and API calls
Now imagine this:
const data = readFileSync("largeFile.txt");
console.log(data);
If the file is large, Node.js will:
Stop execution
Wait until the file is fully read
Then move forward
This creates a problem:
While waiting, the server cannot handle other requests.
Solution: Asynchronous Code
Instead of waiting, Node.js uses async code.
The idea is simple:
Start a task, and when it finishes, notify me. Meanwhile, I will continue doing other work.
Example:
readFile("file.txt", (err,data) => {
console.log(data);
});
console.log("This runs first");
Output:
This runs first
(file content later)
Node.js does not block execution, which makes it fast and efficient.
Callback-Based Async Execution
The first approach to handling async operations in Node.js was callbacks.
A callback is simply a function passed as an argument to another function, which is executed later.
Example:
function fetchData(callback) {
setTimeout(() => {
callback("Data received");
},2000);
}
fetchData((data) => {
console.log(data);
});
Flow:
The async task starts
Node.js continues execution
Once the task finishes, the callback runs
Problems with Nested Callbacks (Callback Hell)
Now consider multiple dependent async operations:
loginUser((user) => {
getProfile(user, (profile) => {
getPosts(profile, (posts) => {
console.log(posts);
});
});
});
This structure is called callback hell.
Problems:
Code becomes deeply nested
Hard to read and understand
Debugging becomes difficult
Error handling is inconsistent
This pattern is often called the "pyramid of doom".
Promise-Based Async Handling
To solve the problems of callbacks, JavaScript introduced promises.
A promise represents a value that will be available in the future.
It has three states:
Pending
Resolved
Rejected
Example using Promise
function fetchData() {
return new Promise((resolve,reject) => {
setTimeout(() => {
resolve("Data received");
},2000);
});
}
fetchData()
.then((data) => {
console.log(data);
})
.catch((err) => {
console.error(err);
});
Chaining Promises
loginUser()
.then((user) =>getProfile(user))
.then((profile) =>getPosts(profile))
.then((posts) =>console.log(posts))
.catch((err) =>console.error(err));
This removes nesting and creates a clear, linear flow.
Benefits of Promises
1. Better readability
Code is flatter and easier to understand compared to nested callbacks.
2. Centralized error handling
Instead of handling errors at every step, a single .catch() can handle failures.
3. Easy chaining of async operations
Multiple async steps can be connected in a clean sequence.
4. Foundation for async/await
Promises enable modern syntax like async/await:
async function run() {
const data = await fetchData();
console.log(data);
}
This looks like synchronous code but works asynchronously.
Final Thoughts
Node.js uses async code to avoid blocking execution
Callbacks were the first solution but led to complex and unreadable code
Promises improved structure and made async code easier to manage
Today, async/await is the most commonly used approach, built on top of promises



