Async Code in Node.js: Callbacks and Promises

JavaScript is single-threaded, which means it can only do one thing at a time. But web servers need to handle hundreds of requests simultaneously. That's where asynchronous code comes in — it lets you start a task, move on to something else, and get notified when that task finishes.
In this article we will cover how async code works in Node.js, starting with callbacks and moving into the more modern promise-based approach.
Why Async Code Exists
Imagine you're reading a file from disk. This takes time — maybe 100 milliseconds. In synchronous code, your entire application would freeze and wait. That's wasteful.
// Synchronous — blocks everything
let data = fs.readFileSync("data.txt");
console.log("Data read:", data); // Nothing else happens until this completes
With async code, you can start the read and let Node.js continue processing other tasks while the file operation happens in the background:
// Asynchronous — non-blocking
fs.readFile("data.txt", (err, data) => {
console.log("Data read:", data);
});
console.log("This runs immediately, no waiting");
This is how Node.js handles thousands of concurrent connections without breaking a sweat.
Callback-Based Async Execution
A callback is a function you pass to another function. That function runs your callback when the async operation completes.
Simple Callback Example
const fs = require("fs");
function readMyFile(filename, callback) {
fs.readFile(filename, "utf8", (err, data) => {
if (err) {
callback(err, null);
} else {
callback(null, data);
}
});
}
readMyFile("message.txt", (err, data) => {
if (err) {
console.log("Error:", err);
} else {
console.log("File contents:", data);
}
});
console.log("Reading file...");
Output:
Reading file...
File contents: Hello, World!
The callback fires when the file finishes reading. Your code doesn't block — it moves on immediately.
Execution Flow
Start: readMyFile() is called
|
+---> fs.readFile() begins (non-blocking)
|
+---> JavaScript continues running
| console.log("Reading file...") outputs
|
+---> [Waiting for file I/O to complete]
|
+---> File arrives from disk
|
+---> Callback fires
if (err) or console.log outputs
Problems with Nested Callbacks — Callback Hell
When you need to do multiple async operations in sequence, callbacks get nested deeper and deeper. This is called "callback hell" or "the pyramid of doom."
Real-world scenario: Read a file, parse it, save the result
const fs = require("fs");
fs.readFile("input.txt", "utf8", (err, data) => {
if (err) {
console.log("Error reading file:", err);
} else {
// Parse the data
let parsed = JSON.parse(data);
fs.readFile("template.txt", "utf8", (err2, template) => {
if (err2) {
console.log("Error reading template:", err2);
} else {
// Combine data and template
let result = template.replace("{data}", parsed.name);
fs.writeFile("output.txt", result, (err3) => {
if (err3) {
console.log("Error writing file:", err3);
} else {
console.log("Done! Output written.");
}
});
}
});
}
});
Notice how deeply nested this becomes. Each level adds another layer of indentation. With more operations, it becomes a nightmare to read and maintain.
The "Pyramid of Doom"
fs.readFile(..., (err, data) => {
if (!err) {
fs.readFile(..., (err2, template) => {
if (!err2) {
fs.writeFile(..., (err3) => {
if (!err3) {
// Finally, we can do something
console.log("Success!");
}
});
}
});
}
});
Problems:
- Hard to read and follow the logic
- Error handling scattered everywhere
- Easy to lose track of variable scope
- Difficult to refactor or test
Promise-Based Async Handling
A Promise represents an async operation that will eventually produce a result. Instead of nesting callbacks, you chain .then() calls to handle what happens next.
Promise Basics
const fs = require("fs").promises; // Note: .promises API
fs.readFile("data.txt", "utf8")
.then(data => {
console.log("File read:", data);
return data;
})
.catch(err => {
console.log("Error:", err);
});
console.log("Reading file...");
Output:
Reading file...
File read: Hello, World!
The .then() runs when the file is read. The .catch() runs if there's an error.
Promise Lifecycle
Every promise goes through these states:
┌─────────┐
│ Pending │ (operation not done yet)
└────┬────┘
|
┌──────┴──────┐
| |
┌──▼──┐ ┌──▼──┐
│Fulfilled│ │Rejected│ (operation done)
│(success)│ │(error) │
└────────┘ └────────┘
| |
.then() .catch()
Solving Callback Hell with Promises
The same scenario (read file, parse, read template, write output) becomes much cleaner:
const fs = require("fs").promises;
fs.readFile("input.txt", "utf8")
.then(data => {
let parsed = JSON.parse(data);
return fs.readFile("template.txt", "utf8")
.then(template => {
return { parsed, template };
});
})
.then(({ parsed, template }) => {
let result = template.replace("{data}", parsed.name);
return fs.writeFile("output.txt", result);
})
.then(() => {
console.log("Done! Output written.");
})
.catch(err => {
console.log("Error:", err);
});
Much flatter. All error handling in one .catch() block at the end. The flow is easier to follow.
Creating Your Own Promise
function delay(ms) {
return new Promise((resolve, reject) => {
if (ms < 0) {
reject("Time cannot be negative");
} else {
setTimeout(() => {
resolve("Delay complete!");
}, ms);
}
});
}
delay(2000)
.then(result => console.log(result))
.catch(err => console.log(err));
console.log("Waiting...");
Output:
Waiting...
Delay complete! (after 2 seconds)
The new Promise() constructor takes a function with two parameters:
resolve— call this when the operation succeedsreject— call this when something goes wrong
Benefits of Promises
| Aspect | Callbacks | Promises |
|---|---|---|
| Readability | Deeply nested, hard to follow | Flat, left-to-right flow |
| Error handling | Multiple try-catch or if statements | Single .catch() block |
| Chaining | Not possible, leads to nesting | Natural chain with .then() |
| Debugging | Nested stack traces | Clearer error messages |
| Parallelism | Difficult to coordinate | Promise.all() and Promise.race() |
Promise.all() — Wait for Multiple Operations
const fs = require("fs").promises;
Promise.all([
fs.readFile("file1.txt", "utf8"),
fs.readFile("file2.txt", "utf8"),
fs.readFile("file3.txt", "utf8")
])
.then(results => {
console.log("File 1:", results[0]);
console.log("File 2:", results[1]);
console.log("File 3:", results[2]);
})
.catch(err => console.log("Error:", err));
All three files are read in parallel. The .then() fires once all three are done.
Promise.race() — Return First Result
Promise.race([
delay(1000),
delay(3000),
delay(2000)
])
.then(result => console.log("First one done:", result));
The fastest promise wins. Useful for timeouts or competing requests.
Callback vs Promise Side-by-Side
Reading and writing a file with callbacks:
fs.readFile("input.txt", "utf8", (err, data) => {
if (err) {
console.log("Read error:", err);
} else {
let processed = data.toUpperCase();
fs.writeFile("output.txt", processed, (err) => {
if (err) {
console.log("Write error:", err);
} else {
console.log("Done!");
}
});
}
});
Same thing with promises:
fs.readFile("input.txt", "utf8")
.then(data => {
let processed = data.toUpperCase();
return fs.writeFile("output.txt", processed);
})
.then(() => console.log("Done!"))
.catch(err => console.log("Error:", err));
Promises are flatter and the error handling is unified.
Practice Assignment
Work through these steps using Node.js file operations:
1. Create a simple function that returns a promise:
function fetchData(filename) {
return new Promise((resolve, reject) => {
require("fs").readFile(filename, "utf8", (err, data) => {
if (err) reject(err);
else resolve(data);
});
});
}
2. Use .then() to handle the result:
fetchData("data.txt")
.then(data => console.log("Data:", data))
.catch(err => console.log("Error:", err));
3. Chain multiple operations:
fetchData("input.txt")
.then(data => {
console.log("Read successful");
return data.toUpperCase();
})
.then(processed => {
console.log("Processed:", processed);
})
.catch(err => console.log("Error:", err));
4. Try Promise.all() with multiple files:
Promise.all([
fetchData("file1.txt"),
fetchData("file2.txt"),
fetchData("file3.txt")
])
.then(results => console.log("All files read:", results))
.catch(err => console.log("Error:", err));
Run these in your Node.js environment and observe how promises handle async operations.
Quick Recap
Node.js is single-threaded, so async code is essential for handling concurrent operations
Callbacks are functions you pass to other functions. They run when the async operation completes
Nested callbacks create "callback hell" — deeply indented, hard to read code
Promises represent operations that will eventually produce a result — either success or failure
Promises have three states: Pending, Fulfilled, and Rejected
.then()handles success,.catch()handles errorsPromises allow clean chaining instead of nesting, making code more readable
Promise.all()waits for multiple promises in parallelPromise.race()returns the first promise to completeModern async/await syntax builds on promises (covered in future articles)
Promises transformed how JavaScript handles async operations. Once you master them, async code becomes intuitive and maintainable.
Happy coding! 🚀
If you enjoyed this article, check out my other blogs on this profile. 🔗 Connect with me: LinkedIn | GitHub | X (Twitter)




