Why Async Matters in Node.js
Node.js is single-threaded and uses an event-driven, non-blocking I/O model. This means operations like reading files, making network requests, or querying databases don't block the main thread. Understanding async patterns is crucial for writing efficient Node.js code.
🔄 The Evolution of Async
Callbacks
Original way
→
Promises
ES6 (2015)
→
Async/Await
ES2017
Callbacks
The original async pattern in Node.js. A function passed as an argument to be called later.
const fs = require('fs');
// Callback pattern: error-first callback
fs.readFile('file.txt', 'utf8', (err, data) => {
if (err) {
console.error('Error:', err);
return;
}
console.log('Data:', data);
});
// Callback Hell (avoid this!)
fs.readFile('file1.txt', 'utf8', (err, data1) => {
if (err) return console.error(err);
fs.readFile('file2.txt', 'utf8', (err, data2) => {
if (err) return console.error(err);
fs.readFile('file3.txt', 'utf8', (err, data3) => {
if (err) return console.error(err);
console.log(data1, data2, data3);
// This nesting can go on forever...
});
});
});
Promises
Promises represent a value that may be available now, later, or never. They have three states: pending, fulfilled, or rejected.
// Creating a Promise
const myPromise = new Promise((resolve, reject) => {
const success = true;
setTimeout(() => {
if (success) {
resolve('Operation succeeded!');
} else {
reject(new Error('Operation failed'));
}
}, 1000);
});
// Using Promises
myPromise
.then(result => console.log(result))
.catch(error => console.error(error))
.finally(() => console.log('Done'));
// Promise with fs
const fs = require('fs').promises;
fs.readFile('file.txt', 'utf8')
.then(data => console.log(data))
.catch(err => console.error(err));
// Chaining Promises (solves callback hell)
fs.readFile('file1.txt', 'utf8')
.then(data1 => {
console.log(data1);
return fs.readFile('file2.txt', 'utf8');
})
.then(data2 => {
console.log(data2);
return fs.readFile('file3.txt', 'utf8');
})
.then(data3 => {
console.log(data3);
})
.catch(err => console.error(err));
Promise Utilities
// Promise.all - Wait for all promises to resolve
const promise1 = fs.readFile('file1.txt', 'utf8');
const promise2 = fs.readFile('file2.txt', 'utf8');
const promise3 = fs.readFile('file3.txt', 'utf8');
Promise.all([promise1, promise2, promise3])
.then(([data1, data2, data3]) => {
console.log('All files loaded');
})
.catch(err => console.error('One failed:', err));
// Promise.allSettled - Wait for all, even if some fail
Promise.allSettled([promise1, promise2, promise3])
.then(results => {
results.forEach((result, i) => {
if (result.status === 'fulfilled') {
console.log(`File ${i + 1}: Success`);
} else {
console.log(`File ${i + 1}: Failed - ${result.reason}`);
}
});
});
// Promise.race - First one to resolve/reject wins
Promise.race([promise1, promise2])
.then(data => console.log('First completed:', data));
// Promise.any - First one to resolve wins (ignores rejections)
Promise.any([promise1, promise2])
.then(data => console.log('First success:', data));
Async/Await
The modern way to write async code. Makes promises look like synchronous code.
const fs = require('fs').promises;
// Basic async/await
async function readFile() {
try {
const data = await fs.readFile('file.txt', 'utf8');
console.log(data);
} catch (err) {
console.error('Error:', err);
}
}
readFile();
// Sequential operations
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(data1, data2, data3);
} catch (err) {
console.error('Error:', err);
}
}
// Parallel operations with async/await
async function readFilesParallel() {
try {
const [data1, data2, data3] = await Promise.all([
fs.readFile('file1.txt', 'utf8'),
fs.readFile('file2.txt', 'utf8'),
fs.readFile('file3.txt', 'utf8')
]);
console.log(data1, data2, data3);
} catch (err) {
console.error('Error:', err);
}
}
Error Handling Patterns
// Try/catch with async/await
async function fetchData() {
try {
const response = await fetch('https://api.example.com/data');
const data = await response.json();
return data;
} catch (error) {
console.error('Failed to fetch:', error);
throw error; // Re-throw if needed
}
}
// Helper function for cleaner error handling
async function tryCatch(promise) {
try {
const data = await promise;
return [null, data];
} catch (error) {
return [error, null];
}
}
// Usage
async function main() {
const [error, data] = await tryCatch(fetchData());
if (error) {
console.error('Error:', error);
return;
}
console.log('Data:', data);
}
Practical Example: API Handler
const express = require('express');
const app = express();
// Async route handler
app.get('/users/:id', async (req, res) => {
try {
const user = await getUserById(req.params.id);
const posts = await getPostsByUserId(user.id);
const comments = await Promise.all(
posts.map(post => getCommentsByPostId(post.id))
);
res.json({ user, posts, comments });
} catch (error) {
console.error(error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Wrapper for async error handling
const asyncHandler = (fn) => (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
app.get('/products', asyncHandler(async (req, res) => {
const products = await getProducts();
res.json(products);
}));
app.listen(3000);
💡 Best Practices
- • Always use async/await for new code
- • Always wrap await in try/catch for error handling
- • Use Promise.all() for parallel operations
- • Avoid mixing callbacks and promises
- • Remember: async functions always return a Promise