Async Code in Node.js: Callbacks and Promises

1) Why Async Code Exists in Node.js (with File Reading Scenario)
Imagine your program needs to read a file. This takes time. If Node.js waits for the file to fully load before doing anything else, the whole program will pause — which is inefficient.
Instead, Node.js uses asynchronous code, so it can start reading the file and continue doing other tasks at the same time.
Example: File Reading
console.log("Start");
readFile("file.txt", (err, data) => {
console.log("File content:", data);
});
console.log("End");
Output Flow
Start
End
File content: (after some time)
Node.js does NOT wait for the file to finish reading.
Callback Flow (Step-by-Step):
this is called non-blocking execution
readFile()is calledNode.js sends file reading task to the background
Moves to next line → prints
"End"Once file reading is done
Callback function executes with the result
Problem with Callbacks
This is called callback hell
When tasks depend on each other, callbacks become messy:
readFile("file1.txt", (err, data1) => {
readFile("file2.txt", (err, data2) => {
readFile("file3.txt", (err, data3) => {
console.log(data1, data2, data3);
});
});
});
Solution: Promises (Better Readability)
readFilePromise("file1.txt")
.then(data1 => readFilePromise("file2.txt"))
.then(data2 => readFilePromise("file3.txt"))
.then(data3 => console.log(data3))
.catch(err => console.error(err));
Cleaner and easier to follow
Callback vs Promise (Comparison)
| Feature | Callback | Promise |
|---|---|---|
| Readability | Poor (nested) | Clean (chained) |
| Error Handling | Difficult | Easy with .catch() |
| Scalability | Hard to manage | Easier to scale |
2) Callback-Based Async Execution
Callback-based async execution is a way to handle tasks in Node.js where a function (called a callback) is passed into another function and executed after the task is completed.
Real-Life Scenario :-
Imagine you ask someone to bring you a file . Instead of waiting idle, you continue doing other work. Once the file is ready, they come back and give it to you.
That “come back and inform” part is like a callback function.
Example: Callback-Based File Reading
console.log("Start");
readFile("file.txt", (err, data) => {
if (err) {
console.log("Error:", err);
return;
}
console.log("File content:", data);
});
console.log("End");
Execution Flow :-
"Start"is printedreadFile()is calledNode.js sends file reading task to background
"End"is printed immediatelyOnce file is ready → callback function runs
File content is displayed
Output
Start
End
File content: (after some time)
The program does not wait for the file — this is called non-blocking execution
Structure of a Callback
function task(callback) {
// do something
callback();
}
A callback is simply a function passed as an argument.
Error-First Callback Pattern
function(err, data) {
if (err) {
// handle error
} else {
// use data
}
}
First parameter = error 👉 Second parameter = result
Problem: Callback Hell
When multiple async tasks depend on each other:
readFile("file1.txt", (err, data1) => {
readFile("file2.txt", (err, data2) => {
readFile("file3.txt", (err, data3) => {
console.log(data1, data2, data3);
});
});
});
Deep nesting makes code:
Hard to read
Hard to debug
Hard to maintain
Why Still Important?
Core concept of async programming
Used in many older APIs
Foundation for Promises and async/await
3) Problems with Nested Callbacks (Callback Hell)
When callbacks are placed inside other callbacks repeatedly, the code becomes deeply nested. This situation is commonly called “callback hell” and creates several practical problems.
Imagine you need to read three files one after another. Each file depends on the previous one. Using callbacks, you might write code like this:
readFile("file1.txt", (err, data1) => {
if (err) return console.error(err);
readFile("file2.txt", (err, data2) => {
if (err) return console.error(err);
readFile("file3.txt", (err, data3) => {
if (err) return console.error(err);
console.log(data1, data2, data3);
});
});
});
This deeply nested structure is called callback hell
Callback Flow (Step-by-Step)
readFile("file1.txt")startsAfter completion → callback runs
Inside it,
readFile("file2.txt")startsAfter completion → its callback runs
Inside it,
readFile("file3.txt")startsFinal callback prints all data
👉 Each step waits for the previous one, causing nested execution
Problems with Nested Callbacks
1) Poor Readability
Code becomes hard to understand
Too many nested levels (pyramid shape)
2) Difficult Debugging
Errors can occur at multiple levels
Hard to trace where things went wrong
3) Error Handling Issues
Need to handle errors in every callback
Easy to miss or duplicate error handling
4) Hard to Maintain
Adding new steps makes code more complex
Refactoring becomes difficult
Solution: Promises (Cleaner Approach)
readFilePromise("file1.txt")
.then(data1 => readFilePromise("file2.txt"))
.then(data2 => readFilePromise("file3.txt"))
.then(data3 => console.log(data3))
.catch(err => console.error(err));
No nesting, just a clean chain
Callback vs Promise
| Feature | Callback | Promise |
|---|---|---|
| Structure | Nested (pyramid) | Flat (chain) |
| Readability | Hard to read | Easy to follow |
| Error Handling | Repeated everywhere | Single .catch() |
| Maintenance | Difficult | Easier |
4) Promise-Based Async Handling
A Promise is an object in JavaScript that represents the result of an asynchronous operation. It can be in one of three states: pending, fulfilled, or rejected. Promises help avoid callback hell and make async code more readable and manageable.
Imagine the same file reading scenario where you need to read files one after another. Using Promises, the code becomes cleaner and easier to follow.
Example:
readFilePromise("file1.txt")
.then(data1 => {
console.log("File1:", data1);
return readFilePromise("file2.txt");
})
.then(data2 => {
console.log("File2:", data2);
return readFilePromise("file3.txt");
})
.then(data3 => {
console.log("File3:", data3);
})
.catch(err => {
console.error("Error:", err);
});
This structure avoids deep nesting and keeps the flow linear.
Promise Flow (Step-by-Step)
readFilePromise("file1.txt") starts After completion → first .then() runs Inside it, readFilePromise("file2.txt") starts After completion → second .then() runs Inside it, readFilePromise("file3.txt") starts After completion → final .then() runs If any error occurs → .catch() handles it
👉 Each step runs in sequence, but without nesting
Key Advantages of Promises
Better Readability Code looks clean and structured
Centralized Error Handling Single .catch() handles all errors
Avoids Callback Hell No deeply nested functions
Easier to Maintain Simple to add or modify steps
Promise States
Pending → operation not completed Fulfilled → operation successful Rejected → operation failed
5) Benefits of Promises
Promises are used to handle asynchronous operations in a cleaner and more structured way compared to callbacks.
Key Benefits
1) Better Readability
Code is written in a clear, linear flow using .then() instead of deep nesting
2) Avoids Callback Hell
No pyramid structure — promises use chaining, making code easier to manage
3) Centralized Error Handling
Errors can be handled in one place using .catch() instead of repeating checks
4) Improved Maintainability
Easier to update, extend, and refactor code
5) Chaining Support
Multiple async operations can be linked step-by-step
Example :
readFilePromise("file1.txt")
.then(data1 => readFilePromise("file2.txt"))
.then(data2 => readFilePromise("file3.txt"))
.then(data3 => console.log(data3))
.catch(err => console.error(err));
Better Control Flow Provides methods like:
.then()→ for success.catch()→ for errors.finally()→ runs always
Final Summary
Node.js uses asynchronous code to avoid blocking while handling tasks like file reading, allowing it to run other code simultaneously (non-blocking execution). Callbacks execute after a task completes but can lead to “callback hell” when nested, making code hard to read and maintain. Promises solve this by providing a clean, chained structure with better readability and centralized error handling, making async code easier to manage.



