Node.js Promise.allSettled: Handling Concurrent Async Tasks in Express
Master Node.js concurrent requests with Promise.allSettled in Express. Learn to handle partial failures and keep your API robust during async tasks.
When you're building high-performance APIs in Node.js, you'll eventually hit a wall where a single route needs to aggregate data from three or four different microservices or database tables. If you use Promise.all for this, one service timing out or throwing a 500 effectively nukes the entire request.
I remember debugging a dashboard endpoint that aggregated user profile data, recent orders, and notification settings. We used Promise.all because it felt clean. But when the notification service had a transient blip, the whole dashboard failed for the user. It was a classic "all or nothing" trap.
Why Promise.allSettled is the Better Choice
Promise.allSettled solves the partial success problem. Unlike Promise.all, which short-circuits on the first rejection, allSettled waits for every promise to finish, regardless of whether it succeeded or failed. You get an array of result objects back, each clearly stating its status as either fulfilled or rejected.
This pattern is essential for Node.js async error handling in Express: a practical guide because it allows you to return partial data to the client rather than a generic error message.
Implementing the Pattern in Express
Let's look at a concrete example. Imagine an endpoint that fetches data from three external APIs.
JAVASCRIPTconst express = require(CE9178">'express'); const app = express(); app.get(CE9178">'/user-dashboard', async (req, res) => { const userId = req.query.id; // Fire off all requests concurrently const results = await Promise.allSettled([ fetchProfile(userId), fetchOrders(userId), fetchNotifications(userId) ]); // Map the results to a clean response const response = { profile: results[0].status === CE9178">'fulfilled' ? results[0].value : null, orders: results[1].status === CE9178">'fulfilled' ? results[1].value : [], notifications: results[2].status === CE9178">'fulfilled' ? results[2].value : [], errors: results .filter(r => r.status === CE9178">'rejected') .map(r => r.reason.message) }; res.json(response); });
Handling the Trade-offs
You might be tempted to just use Promise.all and wrap each individual call in a try/catch. I’ve been there. It works, but the code gets incredibly verbose very quickly.
| Feature | Promise.all | Promise.allSettled |
|---|---|---|
| Fail-fast | Yes (stops at first error) | No (waits for all) |
| Result Type | Array of values | Array of {status, value/reason} |
| Best Use Case | Dependent tasks | Independent concurrent tasks |
| Error Handling | Requires individual catch | Handled via status check |
Using allSettled keeps the controller logic flat. If you need to manage these transient failures with retries, I highly recommend checking out my post on the Node.js retry pattern: handling transient failures with Express.
A Note on Production Readiness
When using this pattern, always ensure your individual task functions are wrapped properly. If you leave an unhandled rejection, you're going to run into the issues I outlined in Node.js security: fixing unhandled promise rejections and async errors.
One thing I'm still careful about is memory usage. If you're firing off 50+ concurrent requests, you might want to look into using a concurrency-limiting library like p-limit. Node.js is excellent at handling thousands of connections, but you don't want to overwhelm your downstream services.
FAQ
Q: Does Promise.allSettled catch syntax errors in my code? A: No. It only catches rejections of the promises you pass into it. If your code throws a synchronous error, it will still bubble up to your Express error-handling middleware.
Q: Can I use this for database queries?
A: Absolutely. Most modern ORMs like Prisma or native drivers return promises, making them perfect candidates for Promise.allSettled.
Q: How do I handle a scenario where one failure is critical?
A: You can inspect the results array immediately after the await. If a critical service (like Auth) fails, you can manually trigger an error response even if other promises succeeded.
I've found that moving to allSettled makes my APIs significantly more resilient to the "noisy neighbor" effect of microservices. It's a small change in syntax that makes a massive difference in user experience. Next time, I'm going to experiment with streaming these results back to the client as they arrive, rather than waiting for the slowest one to finish.
