Async Code in Node.js: Callbacks and Promises
A beginner friendly breakdown of asynchronous JavaScript in Node.js callbacks, callback hell, and how promises fix everything with clean flow.

Hi, I’m Abdul Samad. A web development learner and tech enthusiast. I write about what I learn, share practical coding tips, and publish in-depth blogs on programming and modern web development.
Check out my full collection of blogs on Hashnode: https://abdulsamad30.hashnode.dev/
Connect with me on X for quick updates and insights: @abdul_sama60108
If you've spent even a little time with Node.js, you've probably heard the words "asynchronous," "callback," or "promise" thrown around. And if you're coming from a synchronous programming background, these concepts can feel a bit like learning to drive on the left side of the road everything looks familiar, but something feels off.
In this blog, we're going to break all of this down step by step. We'll start with why async code even exists in Node.js, move into how callbacks work, talk honestly about their problems, and then show how promises clean things up beautifully. Let's get into it.
Why Async Code Exists in Node.js
Node.js runs on a single thread. That means it can only do one thing at a time but here's the clever part: it doesn't wait around when it doesn't have to.
Think about ordering coffee. You place your order, and instead of standing frozen at the counter staring at the barista until your coffee is ready, you step aside, check your phone, maybe grab a muffin. When your coffee is ready, you're called. That's the core idea behind async code don't block, keep moving, and handle things when they're ready.
In Node.js, many operations like reading files, making database queries, or calling external APIs take time. If Node.js waited for each of these to finish before moving on, your server would grind to a halt under real-world load. Async code solves this by letting Node.js offload those operations and continue executing other code in the meantime.
const fs = require('fs');
console.log("Before reading file");
fs.readFile('message.txt', 'utf8', function (err, data) {
console.log("File content:", data);
});
console.log("After reading file");
Expected Output:
Before reading file
After reading file
File content: Hello from the file!
Notice that "After reading file" prints before the file content. Node.js didn't wait it fired off the file read and moved on. That's async behavior in action.
Callback-Based Async Execution
So how does Node.js know what to do after the async operation finishes? That's where callbacks come in.
A callback is simply a function that you pass as an argument to another function, with the instruction: "when you're done, call this." It's like leaving your phone number at a restaurant they'll call you when your table is ready.
Let's walk through a file reading example step by step.
Step 1: You call fs.readFile() and pass it the filename, encoding, and a callback function.
Step 2: Node.js sends the file read request to the operating system and immediately moves on.
Step 3: Your callback function sits and waits in the background.
Step 4: Once the file is read, Node.js picks up the callback and calls it with either an error or the file data.
const fs = require('fs');
// Step 1: Call readFile with a callback
fs.readFile('user.txt', 'utf8', function (err, data) {
// Step 4: This runs only after the file is ready
if (err) {
console.log("Error reading file:", err.message);
return;
}
console.log("User data:", data);
});
// Step 2 & 3: This runs immediately while file is being read
console.log("Waiting for file...");
Expected Output:
Waiting for file...
User data: John Doe, age 28
The callback receives two arguments err and data. This is a Node.js convention: always pass the error first. If something goes wrong, err will have the details. If everything is fine, data holds your result.
Problems with Nested Callbacks
Callbacks work fine for simple tasks. But real-world applications rarely do just one async thing. You often need to read a file, then use that data to query a database, then send the result to an API each step depending on the previous one.
This is where callbacks start to hurt.
const fs = require('fs');
fs.readFile('config.txt', 'utf8', function (err, configData) {
if (err) return console.log("Config read error:", err.message);
fs.readFile('user.txt', 'utf8', function (err, userData) {
if (err) return console.log("User read error:", err.message);
fs.readFile('orders.txt', 'utf8', function (err, orderData) {
if (err) return console.log("Order read error:", err.message);
console.log("Config:", configData.trim());
console.log("User:", userData.trim());
console.log("Orders:", orderData.trim());
});
});
});
Expected Output:
Config: timeout=30
User: John Doe
Orders: Order#101, Order#102
The code works, but look at it. Each callback is nested inside the previous one, pushing the code further and further to the right. This pattern is so common it has a nickname Callback Hell (sometimes called the "Pyramid of Doom").
The problems don't stop at ugly formatting:
Error handling becomes repetitive you have to check for errors at every single level manually.
Logic becomes hard to follow trying to understand what runs when requires mentally unwinding all the nesting.
Maintenance becomes painful adding or changing a step means reworking a fragile nested structure.
It works, but there has to be a better way. And there is.
Promise-Based Async Handling
Promises were introduced to give async code a cleaner, more manageable structure. A Promise is an object that represents a value that isn't available yet but will be at some point either successfully resolved or rejected with an error.
You can think of a promise like a tracking number for a package. The package isn't in your hands yet, but you have a confirmation that something is coming. When it arrives successfully, you handle it. If delivery fails, you deal with that too.
A promise has three possible states:
Pending the operation is still in progress
Fulfilled the operation completed successfully
Rejected the operation failed
Let's rewrite the file reading example using promises. We'll wrap fs.readFile in a promise manually to show how it works:
const fs = require('fs');
function readFilePromise(filename) {
return new Promise(function (resolve, reject) {
fs.readFile(filename, 'utf8', function (err, data) {
if (err) {
reject(err); // Something went wrong
} else {
resolve(data); // All good, pass the data
}
});
});
}
readFilePromise('user.txt')
.then(function (data) {
console.log("User data:", data.trim());
})
.catch(function (err) {
console.log("Error:", err.message);
});
Expected Output:
User data: John Doe, age 28
The flow is already cleaner. But where promises really shine is when you chain multiple async operations together.
readFilePromise('config.txt')
.then(function (configData) {
console.log("Config:", configData.trim());
return readFilePromise('user.txt');
})
.then(function (userData) {
console.log("User:", userData.trim());
return readFilePromise('orders.txt');
})
.then(function (orderData) {
console.log("Orders:", orderData.trim());
})
.catch(function (err) {
console.log("Something failed:", err.message);
});
Expected Output:
Config: timeout=30
User: John Doe
Orders: Order#101, Order#102
Each .then() receives the result of the previous one. If anything goes wrong at any point, the single .catch() at the bottom handles it. Clean, flat, and readable.
Benefits of Promises
Let's be honest promises aren't magic. They're still async. But they solve the structural problems that callbacks create. Here's what makes them genuinely better:
1. Flat and readable code structure
Instead of deeply nested callbacks, you get a clean chain of .then() calls. Reading it top to bottom actually makes sense.
2. Centralized error handling
With callbacks, you need to check for errors at every single step. With promises, one .catch() at the end of the chain handles any failure that happens anywhere along the way.
readFilePromise('data.txt')
.then(data => {
console.log("Step 1 done");
return readFilePromise('more-data.txt');
})
.then(moreData => {
console.log("Step 2 done");
})
.catch(err => {
// Handles errors from Step 1 OR Step 2
console.log("Caught error:", err.message);
});
Expected Output (if a file doesn't exist):
Caught error: ENOENT: no such file or directory, open 'more-data.txt'
3. Chaining is natural
Returning a value or another promise inside .then() automatically passes it to the next .then(). The flow of data is explicit and logical.
4. Better for complex workflows
Promises can be combined using utilities like Promise.all() to run multiple async operations in parallel and wait for all of them to finish something that's genuinely painful to do with raw callbacks.
Callback vs. Promise: A Side-by-Side Comparison
Let's put the two approaches next to each other for the same task reading two files in sequence.
With Callbacks:
fs.readFile('file1.txt', 'utf8', function (err, data1) {
if (err) return console.log("Error:", err.message);
fs.readFile('file2.txt', 'utf8', function (err, data2) {
if (err) return console.log("Error:", err.message);
console.log(data1.trim(), data2.trim());
});
});
With Promises:
readFilePromise('file1.txt')
.then(data1 => {
console.log(data1.trim());
return readFilePromise('file2.txt');
})
.then(data2 => console.log(data2.trim()))
.catch(err => console.log("Error:", err.message));
Expected Output (both versions):
Hello from file one
Hello from file two
The logic is identical. But the promise version reads like a story one step at a time, with a safety net at the end. The callback version already shows early signs of nesting, and it's only two files deep.
Wrapping Up
Async code in Node.js exists because waiting around is expensive and Node.js was built to handle many things efficiently on a single thread. Callbacks were the original solution, and they work.
But as soon as your logic gets even slightly complex, they create deeply nested, hard-to-maintain code.
Promises stepped in and gave us a better model flat chaining, centralized error handling, and code that's actually pleasant to read. They represent the same underlying async behavior, but wrapped in a structure that grows with your application instead of against it.
If you're just starting out with Node.js, getting comfortable with both callbacks and promises is essential. Understanding callbacks teaches you how async really works under the hood. Understanding promises teaches you how to write async code that other developers (including future you) can actually follow.
The journey doesn't stop here there's more async goodness ahead with async/await, which builds directly on top of promises. But that's a story for another blog.
FIN ✌️




