Async/Await and Error Handling in Node.js
1. Overview
async/await helps you write asynchronous code in Node.js that looks like synchronous code, much easier to read than callbacks or chains of .then(). But if you don’t understand the underlying mechanism, you’ll easily write error handling incorrectly, leading to UnhandledPromiseRejection that crashes your entire application.
This article starts with how async/await works, covers proper error-handling patterns, and goes through common mistakes when combining it with Promise.all, loops, and Express middleware. After reading, you’ll know how to write safe async functions and debug asynchronous errors faster.
2. How Async/Await Works
A function declared with async always returns a Promise, no matter what value you return inside. await only “pauses” at the call point, yielding control back to the event loop, without blocking the thread.
async function getUser(id) {
const response = await fetch(`https://api.example.com/users/${id}`);
const user = await response.json();
return user;
}
getUser(1).then(user => console.log(user.name));
The line await fetch(...) waits for the Promise from fetch to resolve before continuing. If fetch rejects (for example, losing network connection), the error will be thrown immediately at that await point — and if you don’t catch it, it will reject the Promise that getUser returns.
Note: An
async functionalways returns a Promise, even if you don’tawaitanything inside. Calling it and forgetting toawaitor.then()is the most common cause of bugs where “code runs out of order”.
3. Catching Errors with try/catch
The standard way to handle errors in an async function is to wrap try/catch around your await points.
async function getOrder(orderId) {
try {
const order = await orderRepository.findById(orderId);
if (!order) {
throw new Error(`Order ${orderId} not found`);
}
return order;
} catch (err) {
console.error('Failed to get order:', err.message);
throw err; // re-throw so caller knows and can handle further
}
}
trywraps all logic that might throw or reject.catch (err)receives both manually thrown errors and Promise rejections.throw errat the end to avoid “swallowing” the error — the caller still gets a rejected Promise.
Note: If you
catchan error but don’tthrowit back and don’treturna valid value, the async function will resolve withundefined. The caller thinks everything is fine but the data is missing — a very hard-to-spot bug.
3.1. Custom Error Classes
To categorize errors clearly (business errors, system errors, etc.), you should create custom error classes.
class PaymentError extends Error {
constructor(message, code) {
super(message);
this.name = 'PaymentError';
this.code = code;
}
}
async function chargeCard(order) {
const result = await paymentGateway.charge(order.total);
if (!result.success) {
throw new PaymentError('Card declined', 'CARD_DECLINED');
}
return result;
}
When catching errors, you can check err instanceof PaymentError to handle different error types, instead of comparing error message strings which are error-prone.
4. Errors in Promise.all and Promise.allSettled
Promise.all runs multiple Promises in parallel, but as soon as one rejects, the entire thing rejects immediately, while the remaining Promises still run “in the background” but their results are discarded.
async function notifyCustomers(customers) {
const results = await Promise.all(
customers.map(c => emailService.send(c.email))
);
return results;
}
If emailService.send for the second customer fails, the entire function rejects immediately — you don’t know if customers 1, 3, and 4 sent successfully or not.
When you want to know the result of all Promises, regardless of whether they fail, use Promise.allSettled:
async function notifyCustomersSafely(customers) {
const results = await Promise.allSettled(
customers.map(c => emailService.send(c.email))
);
const failed = results.filter(r => r.status === 'rejected');
if (failed.length > 0) {
console.warn(`${failed.length} email(s) failed to send`);
}
return results;
}
Promise.allSettled never rejects. Each element in results has the form { status: 'fulfilled', value } or { status: 'rejected', reason }. You decide how to handle them next.
[
{ status: 'fulfilled', value: true },
{ status: 'rejected', reason: Error: SMTP timeout },
{ status: 'fulfilled', value: true }
]
5. Common Mistakes Using Await in Loops
Using await inside forEach is a very common mistake — forEach’s callback doesn’t wait for async operations.
// WRONG: forEach doesn't wait for async
async function processOrders(orders) {
orders.forEach(async order => {
await orderService.process(order);
});
console.log('Done'); // runs immediately, doesn't wait for orders to process
}
forEach calls the async callback and ignores the returned Promise, never waiting for it to resolve. console.log('Done') runs before any order is processed — if process throws an error, that error isn’t caught anywhere, causing UnhandledPromiseRejection.
Fix it with for...of if you need to process sequentially:
async function processOrdersSequentially(orders) {
for (const order of orders) {
await orderService.process(order);
}
console.log('Done');
}
Or use Promise.all with map if you want to process in parallel:
async function processOrdersParallel(orders) {
await Promise.all(orders.map(order => orderService.process(order)));
console.log('Done');
}
6. Error Handling in Express Middleware
Express doesn’t catch errors from async functions in route handlers by default — if you throw inside, the request will hang or crash the process in older Node versions (Express 4 doesn’t automatically catch Promise rejections).
// RISKY: async errors aren't automatically caught by Express
app.get('/orders/:id', async (req, res) => {
const order = await orderService.getOrder(req.params.id);
res.json(order);
});
The safe way is to write a wrapper that passes errors to next():
function asyncHandler(fn) {
return (req, res, next) => {
fn(req, res, next).catch(next);
};
}
app.get('/orders/:id', asyncHandler(async (req, res) => {
const order = await orderService.getOrder(req.params.id);
if (!order) {
const err = new Error('Order not found');
err.statusCode = 404;
throw err;
}
res.json(order);
}));
app.use((err, req, res, next) => {
res.status(err.statusCode || 500).json({ message: err.message });
});
asyncHandler wraps the route handler. If the returned Promise rejects, .catch(next) passes the error to Express’s error-handling middleware instead of letting it fall through.
Note: Express 5 (in beta at the time of writing) auto-catches rejections from async handlers, but with the widely-used Express 4, you still need this wrapper.
7. Timeout and AbortController
An easy-to-miss error: await-ing a Promise that never resolves will hang the function indefinitely. Use AbortController to limit the wait time.
async function fetchWithTimeout(url, timeoutMs = 3000) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, { signal: controller.signal });
return await response.json();
} catch (err) {
if (err.name === 'AbortError') {
throw new Error(`Request to ${url} timed out after ${timeoutMs}ms`);
}
throw err;
} finally {
clearTimeout(timer);
}
}
setTimeout calls controller.abort() after timeoutMs, making fetch reject with an error named AbortError. finally always cleans up the timer to prevent memory leaks whether it succeeds or fails.
8. Writing Tests for Async Functions
Use Jest to test both success and error cases.
<!-- package.json (dependencies section, not Maven) -->
npm install --save-dev jest
// orderService.js
async function getOrder(orderId, repository) {
const order = await repository.findById(orderId);
if (!order) {
throw new Error(`Order ${orderId} not found`);
}
return order;
}
module.exports = { getOrder };
// orderService.test.js
const { getOrder } = require('./orderService');
describe('getOrder', () => {
it('returns order when found', async () => {
const mockRepo = { findById: jest.fn().mockResolvedValue({ id: 1, total: 100 }) };
const order = await getOrder(1, mockRepo);
expect(order).toEqual({ id: 1, total: 100 });
});
it('throws error when order not found', async () => {
const mockRepo = { findById: jest.fn().mockResolvedValue(null) };
await expect(getOrder(99, mockRepo)).rejects.toThrow('Order 99 not found');
});
});
mockResolvedValue simulates a Promise resolving with a given value. rejects.toThrow checks that a Promise rejects with the expected message — this is the standard way to test asynchronous errors, instead of manually wrapping things in try/catch in your test.
PASS ./orderService.test.js
getOrder
✓ returns order when found (3 ms)
✓ throws error when order not found (2 ms)
9. Common Mistakes to Avoid
- Forgetting
await: calling an async function withoutawaitmakes code run out of order, errors get silently swallowed. - Mixing
.then()andawait: easy to confuse, pick one style per function. - Overly broad
try/catch: wrapping logic unrelated to async makes it hard to know where errors come from. - Not re-throwing in catch: makes the caller receive
undefinedinstead of an error, causing silent bugs in upper layers. - Using
forEachwith async callbacks: doesn’t wait, usefor...oforPromise.allinstead.
Note: If your logic has no I/O or asynchronous operations, don’t mark the function as
async— it just adds unnecessary Promise creation overhead.
10. Conclusion
async/await makes asynchronous code in Node.js easier to read, but proper error handling is the key to keeping your application stable. Always wrap try/catch around your await points, re-throw errors when needed, use Promise.allSettled when you want results from all parallel tasks, and don’t forget the wrapper for Express route handlers. Writing tests for both success and error cases gives you confidence when refactoring async logic later.