As developers, we frequently deal with operations that take time to complete. Not everything is accessible to us immediately, and we have to wait for them to complete. For example reading the content of a huge file from local storage or fetching data from a remote source can't be done instantly. These operations have its substages. Your app might need to do a few network roundtrips based on the certificate implementation, to only initiate the connection, then to send the request, and finally to receive the response. In such cases, we need to handle these operations cleverly to ensure that our application remains responsive. This is where asynchronous programming comes into play.
What does synchronous and asynchronous mean? The shortest explanation is that synchronous operations blocks your program from running until the operation is completed. You simply can't do anything else while waiting for the operation to finish. On the other hand, asynchronous operations don't block the execution of your program. You can continue doing other things while waiting for the operation to complete. This is extremely important for building responsive applications that can do multiple tasks at the same time.
The Problem: Long-Running Operations
Imagine you are developing a weather app. When a user lands on your app, you need to fetch the user's location, then fetch the weather data based on the location, and finally display the weather information on the screen. Here is how this process would look like in the early days of Node.js:
getLocation(userId, function(locationErr, location) {
if (locationErr) {
return handleError(locationErr);
}
getWeather(location, function(weatherErr, weather) {
if (weatherErr) {
return handleError(weatherErr);
}
formatData(weather, function(formatErr, result) {
if (formatErr) {
return handleError(formatErr);
}
displayResults(result);
});
});
});
You can see how each function is nested inside the previous one and the code shifts to right with each nested function. This is what we call callback hell. Of course the only problem here is not the indentation, but the readability and maintainability of the code. You probably also noticed how error handling gets duplicated at each level, and the flow can become hard to follow as the nesting grows.
The Evolution of Async Handling
1. Callbacks
The original approach. Callbacks are functions passed as arguments to other functions. They were the first way to handle async operations in JavaScript. Here's an example of reading a file with callbacks:
fs.readFile('config.json', (err, data) => {
if (err) {
console.error('Failed to read file:', err);
return;
}
const config = JSON.parse(data);
console.log('Config loaded:', config);
});
Although it may seem quite straightforward at first look, callbacks have some significant drawbacks. For example,
- Error handling is manual at each level: You have to check for errors after each operation and handle them accordingly.
- No standardized pattern: Different APIs have different conventions for error handling and callback order. Some APIs put the error as the first argument, while others put it last.
- Nesting: Even the simple conditional flows can get lots of nested callbacks, which is hard to read and maintain.
- No return value: Callbacks don't return a value, so you can't easily use the result of one async operation in another.
Callbacks are a good way of handling async operations, but not the most elegant solution when dealing with complex code.
2. Promises
A step forward. Promises were introduced to address the issues with callbacks. They represent a value that may be available now, in the future, or never. Promises provided a cleaner way to handle async operations and simplified error handling. They provide a standardized pattern for async code.
function getLocation(userId) {
return new Promise((resolve, reject) => {
// Async operation
if (success) {
resolve(locationData);
} else {
reject(new Error('Failed to get location'));
}
});
}
getLocation(userId)
.then(location => getWeather(location))
.then(weather => formatData(weather))
.then(result => displayResults(result))
.catch(err => handleError(err));
A promise can be in one of three states: pending, fulfilled, or rejected. The beauty is there is no other fourth state. It is either resolved or not.
They introduced a two core methods, then() and catch(). The then() method is for handling the resolved state, you can get the result of the promise with it, and the catch() method is for handling the rejected state.
On the code above you can see that we are chaining the promises, one after another. Each then() returns a new promise, and we chain them with the next then(). Finally, we add a catch() to handle if any error occurs in our promise chain.
Clearly this is a significant improvement over callbacks, but nothing is perfect. Here are some common issues with promises:
- No cancellation: There is no built-in way to cancel a promise once it is created. You will eventually get the fulfilled or rejected state even if you don't need the result anymore.
- Limited error handling: Promises can only handle one error at a time. If multiple errors occur in the chain, you will only be able to catch the first one.
- Readability: It is pretty clear that promises provide way more readability than callbacks, but chaining multiple promises can still lead to deeply nested code, and it can be hard to follow.
3. Async/Await
The modern approach. Async/await structure was introduced to simplify working with promises. It is actually a syntactic sugar on top of promises. It is making async code look and behave more like synchronous code. It allows us to write asynchronous code that looks synchronous and makes it easier to read and maintain.
async function getWeatherReport(userId) {
try {
const location = await getLocation(userId);
const weather = await getWeather(location);
const result = await formatData(weather);
displayResults(result);
} catch (err) {
handleError(err);
}
}
Notice how the code looks almost synchronous, even though it does asynchronous operations. The await keyword here is used to pause the execution of the function until the promise is resolved. This keyword can only be used inside an async function. By using async/await, you can avoid the callback hell and the chaining of promises.
Async/await is built on top of promises, so you should have a good understanding of promises before diving into async/await.
Step-by-Step Implementation
Let's try to implement a simple example to fetch user data, posts and comments from an API using all three approaches: callbacks, promises, and async/await.
1. Using Callbacks
function getUserData(userId, callback) {
// Simulate API call
setTimeout(() => {
const user = { id: userId, name: 'John Doe' };
callback(null, user);
}, 1000);
}
function getUserPosts(userId, callback) {
setTimeout(() => {
const posts = [{ id: 1, title: 'First post' }];
callback(null, posts);
}, 1000);
}
function getPostComments(postId, callback) {
setTimeout(() => {
const comments = [{ id: 1, text: 'Great post!' }];
callback(null, comments);
}, 1000);
}
// Implementation with callbacks
function fetchUserData(userId) {
getUserData(userId, (err, user) => {
if (err) {
console.error('Error fetching user:', err);
return;
}
console.log('User:', user);
getUserPosts(user.id, (err, posts) => {
if (err) {
console.error('Error fetching posts:', err);
return;
}
console.log('Posts:', posts);
getPostComments(posts[0].id, (err, comments) => {
if (err) {
console.error('Error fetching comments:', err);
return;
}
console.log('Comments:', comments);
});
});
});
}
2. Using Promises
function getUserData(userId) {
return new Promise((resolve) => {
setTimeout(() => {
const user = { id: userId, name: 'John Doe' };
resolve(user);
}, 1000);
});
}
function getUserPosts(userId) {
return new Promise((resolve) => {
setTimeout(() => {
const posts = [{ id: 1, title: 'First post' }];
resolve(posts);
}, 1000);
});
}
function getPostComments(postId) {
return new Promise((resolve) => {
setTimeout(() => {
const comments = [{ id: 1, text: 'Great post!' }];
resolve(comments);
}, 1000);
});
}
// Implementation with promises
function fetchUserData(userId) {
return getUserData(userId)
.then(user => {
console.log('User:', user);
return getUserPosts(user.id);
})
.then(posts => {
console.log('Posts:', posts);
return getPostComments(posts[0].id);
})
.then(comments => {
console.log('Comments:', comments);
})
.catch(err => {
console.error('Error:', err);
});
}
3. Using Async/Await
// The API functions are the same promise-based ones from above
// Implementation with async/await
async function fetchUserData(userId) {
try {
const user = await getUserData(userId);
console.log('User:', user);
const posts = await getUserPosts(user.id);
console.log('Posts:', posts);
const comments = await getPostComments(posts[0].id);
console.log('Comments:', comments);
} catch (err) {
console.error('Error:', err);
}
}
As you can see, the async/await version is much cleaner and easier to read compared to the callback version. The promise version is also an improvement over callbacks, but the chaining can still be hard to follow.
Advanced Considerations
Parallel Execution with Promise.all
Not all async operations need to be executed sequentially. If you have independent operations, you can run them in parallel using Promise.all
:
async function fetchDashboardData(userId) {
try {
const user = await getUserData(userId);
// Get posts and profile info in parallel
const [posts, profileStats, notifications] = await Promise.all([
getUserPosts(userId),
getUserProfileStats(userId),
getUserNotifications(userId)
]);
return { user, posts, profileStats, notifications };
} catch (err) {
console.error('Dashboard data fetch failed:', err);
throw err;
}
}
Promise.all
has this beauty that it can take an array of promises and return a single promise that resolves when all of the input promises have resolved. This can significantly improve the performance of your method when operations are independent.
Error Handling Strategies
Of course, the examples above are quite simple. In real world applications, we need to handle errors more carefully.
async function robustDataFetcher(userId) {
try {
const user = await getUserData(userId);
// Try to get posts, but continue even if it fails
let posts = [];
try {
posts = await getUserPosts(userId);
} catch (postErr) {
console.warn('Failed to fetch posts, continuing anyway:', postErr);
// Log to monitoring system
}
// Critical operation - if this fails, we fail the whole function
const criticalData = await getCriticalData(userId);
return { user, posts, criticalData };
} catch (err) {
// Handle based on error type
if (err.name === 'AuthError') {
// Redirect to login
} else if (err.name === 'RateLimitError') {
// Implement backoff strategy
} else {
// Generic error handling
}
throw err;
}
}
Handling Unhandled Rejections
You should always handle unhandled rejections in your application to prevent crashes. In Node.js, you can listen for the unhandledRejection
event:
// At application startup
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled Rejection at:', promise, 'reason:', reason);
// Consider shutdown or recovery strategies
});
In Express apps, use proper error middleware:
app.get('/data', async (req, res, next) => {
try {
const data = await fetchData();
res.json(data);
} catch (err) {
next(err); // Pass to Express error handler
}
});
// Error handling middleware
app.use((err, req, res, next) => {
console.error(err);
res.status(500).json({ error: 'Something went wrong' });
});
Conclusion
So, what should you use in your Node.js applications?
The answer is: it depends. None of the approaches can be considered as "the best". Don't focus on mastering one specific approach. Stay flexible and understand the core principles for each approach. Use whatever fits your use case. They are all serving the same purpose.
Don't think like "callbacks are old and promises are new". Callback aren't going anywhere. They are still very useful in certain scenarios like event emitters and streams. Get comfortable with all three approaches and use them appropriately. Focus on what matters most and keep your application responsive.
Happy coding!
Top comments (0)