Async Code in Node.js: Callbacks and Promises

Introduction: The Sync vs. Async Reality Check
Imagine you're building a web server that needs to read a file from disk. In a traditional, synchronous world, your code would look something like this:
const fs = require('fs');
const data = fs.readFileSync('myfile.txt', 'utf8');
console.log(data);
console.log('Server is running...');
This works fine for a simple script. But here's the problem: what if reading that file takes 2 seconds? Your entire server freezes for 2 seconds. Every user request gets stuck waiting. That's a disaster for a web application.
This is where asynchronous code comes in. Node.js was built from the ground up to handle async operations, allowing your server to keep working while waiting for slow tasks like file reading, database queries, or API calls to complete.
In this blog, we'll explore why async code exists in Node.js, how callbacks work, their problems, and how promises solve those issues.
Why Async Code Exists in Node.js
Node.js runs on a single-threaded event loop. This means it has one main thread that executes your JavaScript code. At first glance, this sounds limiting. How can one thread handle thousands of concurrent users?
The secret is non-blocking I/O.
When Node.js encounters an I/O operation (like reading a file, querying a database, or making an HTTP request), it doesn't wait for it to finish. Instead, it:
Delegates the task to the system (or a thread pool)
Continues executing the rest of your code
Comes back to handle the result when the task is done
The Coffee Shop Analogy
Think of Node.js like a coffee shop with one barista (the single thread). When a customer orders a latte:
Synchronous approach: The barista takes the order, makes the coffee, hands it over, then takes the next order. If making coffee takes 3 minutes, every other customer waits 3 minutes.
Asynchronous approach: The barista takes the order, starts the espresso machine (which works in the background), takes orders from other customers, and only returns to finish the latte when the machine beeps.
Node.js is the efficient barista. It keeps serving other customers while waiting for slow operations to complete.
Why does this matter?
Web servers handle lots of I/O (databases, files, APIs)
Async code lets one server handle thousands of concurrent connections
Your application stays responsive even under load
Callbacks: The Original Async Pattern
Before promises existed, callbacks were the primary way to handle async operations in Node.js. A callback is simply a function passed as an argument to another function, which gets called ("called back") when the async operation completes.
Let's revisit our file reading example using callbacks:
const fs = require('fs');
fs.readFile('myfile.txt', 'utf8', (error, data) => {
if (error) {
console.error('Failed to read file:', error);
return;
}
console.log('File contents:', data);
});
console.log('Server is running...');
Step-by-Step Execution Flow
fs.readFile()is called with a file path, encoding, and a callback functionNode.js starts reading the file in the background
The code immediately moves to the next line and prints "Server is running..."
When the file reading completes (maybe 2 seconds later), Node.js executes the callback function
The callback checks for errors and logs the file contents
Notice the output order:
Server is running...
File contents: [file data]
The "Server is running..." message appears before the file contents, even though it's written after fs.readFile() in the code. This is the essence of async execution!
Visualizing Callback Execution
Time →
[fs.readFile starts]
↓
[Console.log runs] → "Server is running..."
↓
[File reading completes in background]
↓
[Callback executes] → "File contents: ..."
The Callback Problem: Nesting & Callback Hell
Callbacks work fine for simple async operations. But real-world code often requires multiple async operations that depend on each other.
Imagine you need to:
Read a user file
Parse the user data
Read their preferences file
Update the database
With callbacks, this looks like:
const fs = require('fs');
fs.readFile('user.txt', 'utf8', (err, userData) => {
if (err) {
console.error('Error reading user:', err);
return;
}
const user = JSON.parse(userData);
fs.readFile('preferences.txt', 'utf8', (err, prefData) => {
if (err) {
console.error('Error reading preferences:', err);
return;
}
const preferences = JSON.parse(prefData);
db.updateUser(user.id, preferences, (err, result) => {
if (err) {
console.error('Error updating database:', err);
return;
}
console.log('User updated successfully:', result);
});
});
});
The Problem: Callback Hell
This nesting pattern is called "Callback Hell" or the "Pyramid of Doom" because the code forms a pyramid shape that:
Is hard to read: You're constantly indenting deeper
Is hard to maintain: Adding or removing steps requires restructuring
Makes error handling repetitive: Every callback needs its own error check
Obscures the logical flow: It's hard to see the sequence of operations
Common Beginner Mistakes with Callbacks
Forgetting error handling: Not checking
errin every callbackNot returning after errors: Code continues executing after an error
Throwing exceptions in callbacks: Async errors can't be caught with
try/catchLosing context: Variables from outer scopes can be confusing
Promises: A Cleaner Way to Handle Async
Promises were introduced to solve the problems with callbacks. A Promise is an object that represents the eventual completion (or failure) of an async operation and its resulting value.
Promise States
A promise can be in one of three states:
Pending: Initial state, neither fulfilled nor rejected
Fulfilled (Resolved): Operation completed successfully
Rejected: Operation failed
Promise Lifecycle Flow
[Pending]
↓
┌─────┴─────┐
↓ ↓
[Fulfilled] [Rejected]
↓ ↓
.then() .catch()
Converting Our Example to Promises
Modern Node.js provides promise-based versions of fs methods in fs.promises:
const fs = require('fs').promises;
fs.readFile('user.txt', 'utf8')
.then((userData) => {
const user = JSON.parse(userData);
return fs.readFile('preferences.txt', 'utf8');
})
.then((prefData) => {
const preferences = JSON.parse(prefData);
return db.updateUser(user.id, preferences);
})
.then((result) => {
console.log('User updated successfully:', result);
})
.catch((error) => {
console.error('An error occurred:', error);
});
How Promises Work
fs.readFile()returns a Promise object immediately.then()attaches a callback that runs when the promise fulfillsEach
.then()can return a value (or another promise), which passes to the next.then().catch()handles any error that occurs in the chainThe code reads top-to-bottom, matching the logical flow
Key Improvements Over Callbacks
Flat structure: No more nesting/pyramid
Centralized error handling: One
.catch()handles all errorsChainable: Each
.then()returns a promise for the next stepReadable: The sequence of operations is clear
Why Promises Win: Benefits & Readability Comparison
Let's directly compare the callback and promise versions side by side:
Callback Version (Nested)
fs.readFile('user.txt', 'utf8', (err, userData) => {
if (err) { /* handle */ return; }
const user = JSON.parse(userData);
fs.readFile('preferences.txt', 'utf8', (err, prefData) => {
if (err) { /* handle */ return; }
const preferences = JSON.parse(prefData);
db.updateUser(user.id, preferences, (err, result) => {
if (err) { /* handle */ return; }
console.log('Success:', result);
});
});
});
Promise Version (Chained)
fs.readFile('user.txt', 'utf8')
.then(userData => JSON.parse(userData))
.then(user => fs.readFile('preferences.txt', 'utf8'))
.then(prefData => JSON.parse(prefData))
.then(preferences => db.updateUser(user.id, preferences))
.then(result => console.log('Success:', result))
.catch(error => console.error('Error:', error));
Benefits of Promises
Readability: Linear, top-to-bottom flow vs. nested indentation
Error Handling: One
.catch()vs. error checks in every callbackComposability: Easy to chain, combine, and reuse promises
Better Debugging: Stack traces are clearer
Standardization: Promises are part of JavaScript's core (ES6)
Foundation for async/await: Modern syntax builds on promises
Common Beginner Mistakes with Promises
Forgetting to return values: Each
.then()must return a value or promise for the next stepNot handling rejections: Always include a
.catch()or your errors will be silentMixing callbacks and promises: Stick to one pattern per codebase
Creating unnecessary promise wrappers: Use promise-based APIs when available
Conclusion & Next Steps
You've now learned:
Why Node.js uses async code (single-threaded, non-blocking I/O)
How callbacks work (functions passed to handle async results)
Problems with callbacks (nesting, callback hell, error handling)
How promises solve these issues (chaining, flat structure, centralized errors)
Why promises are better (readability, maintainability, standardization)
What's Next?
Promises are a huge improvement, but there's an even more modern syntax: async/await. It lets you write async code that looks synchronous, making it even easier to read and maintain.
Here's a sneak peek:
async function updateUser() {
try {
const userData = await fs.readFile('user.txt', 'utf8');
const user = JSON.parse(userData);
const prefData = await fs.readFile('preferences.txt', 'utf8');
const preferences = JSON.parse(prefData);
const result = await db.updateUser(user.id, preferences);
console.log('Success:', result);
} catch (error) {
console.error('Error:', error);
}
}
Async/await is built on top of promises, so understanding promises is essential. Once you're comfortable with promises, async/await will feel natural.
