Async Code in Node.js: Callbacks and Promises
Understanding asynchronous code execution from callbacks to promises
Turning chai into code and ideas into full-stack applications. Sharing lessons from my development journey, one commit at a time.
Why Async Code Exists in Node.js
Node.js is single-threaded and non-blocking. When performing I/O operations like reading files, making HTTP requests, or querying databases, Node.js doesn't wait for these operations to complete. Instead, it continues executing other code and handles the result when ready.
Why this matters:
Prevents blocking the main thread
Enables handling multiple operations concurrently
Improves application performance and responsiveness
Callback-Based Async Execution
Callbacks are functions passed as arguments to async operations, executed when the operation completes.
File Reading Example
const fs = require('fs');
// Callback-based file reading
fs.readFile('data.txt', 'utf8', (error, data) => {
if (error) {
console.error('Error reading file:', error);
return;
}
console.log('File content:', data);
});
console.log('This runs before file is read');
Execution Flow:
readFile()is calledNode.js delegates file reading to background worker
"This runs before file is read" prints immediately
When file reading completes, callback executes
File content is logged
Problems with Nested Callbacks
When multiple async operations depend on each other, callbacks create deeply nested code (callback hell).
Callback Hell Example
const fs = require('fs');
// Reading three files sequentially
fs.readFile('file1.txt', 'utf8', (err1, data1) => {
if (err1) {
console.error(err1);
return;
}
fs.readFile('file2.txt', 'utf8', (err2, data2) => {
if (err2) {
console.error(err2);
return;
}
fs.readFile('file3.txt', 'utf8', (err3, data3) => {
if (err3) {
console.error(err3);
return;
}
console.log('All files read:', data1, data2, data3);
});
});
});
Issues:
Deep nesting reduces readability
Error handling becomes repetitive
Code grows horizontally instead of vertically
Difficult to maintain and debug
Promise-Based Async Handling
Promises provide a cleaner way to handle async operations. A promise represents a value that may be available now, later, or never.
Promise States
Pending: Initial state, operation not completed
Fulfilled: Operation completed successfully
Rejected: Operation failed
Using Promises
const fs = require('fs').promises;
// Promise-based file reading
fs.readFile('data.txt', 'utf8')
.then(data => {
console.log('File content:', data);
})
.catch(error => {
console.error('Error reading file:', error);
});
Promise Chaining
const fs = require('fs').promises;
// Reading three files sequentially with promises
fs.readFile('file1.txt', 'utf8')
.then(data1 => {
console.log('File 1:', data1);
return fs.readFile('file2.txt', 'utf8');
})
.then(data2 => {
console.log('File 2:', data2);
return fs.readFile('file3.txt', 'utf8');
})
.then(data3 => {
console.log('File 3:', data3);
})
.catch(error => {
console.error('Error reading files:', error);
});
Async/Await (Modern Syntax)
const fs = require('fs').promises;
async function readFiles() {
try {
const data1 = await fs.readFile('file1.txt', 'utf8');
const data2 = await fs.readFile('file2.txt', 'utf8');
const data3 = await fs.readFile('file3.txt', 'utf8');
console.log('All files:', data1, data2, data3);
} catch (error) {
console.error('Error reading files:', error);
}
}
readFiles();
Benefits of Promises
1. Improved Readability
Promises eliminate nested callbacks and create linear, readable code flow.
2. Better Error Handling
Single .catch() block handles errors from entire promise chain.
fetch('api/users')
.then(response => response.json())
.then(users => users.filter(u => u.active))
.then(activeUsers => console.log(activeUsers))
.catch(error => console.error('Any error caught here:', error));
3. Easier Composition
Promises can be combined using utility methods:
// Run promises in parallel
Promise.all([
fs.readFile('file1.txt', 'utf8'),
fs.readFile('file2.txt', 'utf8'),
fs.readFile('file3.txt', 'utf8')
])
.then(([data1, data2, data3]) => {
console.log('All files read simultaneously');
})
.catch(error => console.error(error));
4. Chaining Operations
Each .then() returns a new promise, enabling sequential operations.
getUserById(userId)
.then(user => getOrdersByUser(user.id))
.then(orders => calculateTotal(orders))
.then(total => applyDiscount(total))
.then(finalAmount => console.log('Final:', finalAmount))
.catch(error => console.error(error));
Callback vs Promise Comparison
| Aspect | Callbacks | Promises |
|---|---|---|
| Readability | Nested, hard to follow | Linear, easy to read |
| Error Handling | Repeated in each callback | Single .catch() block |
| Composition | Difficult | Easy with Promise.all(), Promise.race() |
| Debugging | Stack traces unclear | Better stack traces |
| Code Growth | Horizontal (nested) | Vertical (chained) |
Key Takeaways
Node.js uses async code to prevent blocking operations
Callbacks execute when async operations complete
Nested callbacks create "callback hell" with poor readability
Promises represent eventual completion or failure of async operations
Promises improve code readability, error handling, and composition
Async/await provides synchronous-looking syntax for promises
Use promises (or async/await) for cleaner async code