Node.js’s asynchronous nature is its superpower. This guide will help you understand callbacks, promises, and async/await – the three pillars of async programming in Node.js.
1. Understanding the Node.js Event Loop
The event loop is what enables Node.js to handle thousands of concurrent connections with a single thread:
- Receives callbacks from completed I/O operations
- Executes JavaScript in the main thread
- Delegates I/O operations to libuv’s thread pool
- Returns results via callbacks when ready
2. Callback Pattern (The Original Approach)
const fs = require('fs');
// Read file with callback
fs.readFile('example.txt', 'utf8', (err, data) => {
if (err) {
console.error("Error reading file:", err);
return;
}
console.log("File content:", data);
});
Callback Convention: Node.js callbacks always receive error first (err
), then results (data
).
3. Promise-Based Approach (Modern Alternative)
Using Native Promises
const fs = require('fs').promises;
fs.readFile('example.txt', 'utf8')
.then(data => {
console.log("File content:", data);
})
.catch(err => {
console.error("Error reading file:", err);
});
Creating Your Own Promise
function readFilePromise(path) {
return new Promise((resolve, reject) => {
fs.readFile(path, 'utf8', (err, data) => {
if (err) reject(err);
else resolve(data);
});
});
}
4. Async/Await (Syntactic Sugar for Promises)
async function processFile() {
try {
const data = await fs.readFile('example.txt', 'utf8');
console.log("File content:", data);
const processed = await processData(data);
console.log("Processed:", processed);
} catch (err) {
console.error("Error:", err);
}
}
processFile();
Key Rule: await
can only be used inside async
functions.
5. Comparing All Three Approaches
Approach | Pros | Cons | When to Use |
---|---|---|---|
Callbacks | Most basic, works everywhere | Callback hell, hard to maintain | Legacy code, simple scripts |
Promises | Chainable, better error handling | Still requires .then() | Modern code, when you need control |
Async/Await | Cleanest syntax, try/catch works | Requires modern Node.js | Most new code, complex flows |
6. Common Async Patterns
Parallel Execution
// Using Promise.all
async function fetchAllData() {
const [users, products] = await Promise.all([
fetchUsers(),
fetchProducts()
]);
return { users, products };
}
Sequential Execution
async function processSteps() {
const step1 = await doStep1();
const step2 = await doStep2(step1);
return await doStep3(step2);
}
7. Error Handling Strategies
Try/Catch with Async/Await
async function safeOperation() {
try {
const result = await mightFail();
return result;
} catch (err) {
console.error("Operation failed:", err);
throw err; // Re-throw if needed
}
}
Error-First Callbacks
fs.readFile('file.txt', (err, data) => {
if (err) {
// Handle error
return;
}
// Process data
});
Catch Handler for Promises
fetchData()
.then(processData)
.catch(err => {
console.error("Error in chain:", err);
});
8. Real-World Async Example
Fetching data from multiple APIs with error handling:
async function fetchDashboardData(userId) {
try {
const [user, orders, notifications] = await Promise.all([
fetchUser(userId).catch(() => null),
fetchOrders(userId).catch(() => []),
fetchNotifications(userId).catch(() => [])
]);
if (!user) {
throw new Error('User not found');
}
return {
user,
orders,
notifications,
lastUpdated: new Date()
};
} catch (err) {
console.error('Dashboard error:', err);
throw err;
}
}
Next: Working with the File System →
Async Best Practices
- Always handle errors – never leave promises uncaught
- Use
Promise.all()
for parallel operations - Avoid nesting promises/callbacks too deeply
- Consider using
util.promisify
for callback APIs - Use async/await for better readability