Error handling with async/await
When you move from callback hell to async/await, error handling gets cleaner but also easier to mess up. The try/catch pattern is back—and if you ignore it or misuse it, you'll ship production bugs that silently fail or crash your entire request handler. This day covers the patterns I've learned from shipping real systems: where to catch, what to do when you catch it, and how to keep your error boundaries tight.
Wrap awaits that can fail meaningfully
Not every await needs a try/catch. The rule I follow: wrap awaits that have a meaningful failure mode you can act on. If a call to fetch user data fails, you might return a default user or redirect to login. If it succeeds, you continue. That's a meaningful boundary.
Here's what that looks like in a real API endpoint:
async function getUserProfile(userId: string) {
try {
const user = await db.users.findById(userId);
if (!user) {
throw new Error('User not found');
}
return user;
} catch (error) {
logger.error('Failed to fetch user profile', { userId, error });
throw new HttpException('User not found', 404);
}
}
Notice: I wrapped the database call because I know it can fail (connection drops, timeout, permission denied). I have a specific response ready—a 404 HTTP exception. The caller doesn't need to know whether the user doesn't exist or the database is down; they get a consistent interface.
Now contrast that with wrapping things that almost never fail in your happy path:
// Don't do this
async function processOrder(order: Order) {
try {
const items = JSON.parse(order.itemsJson); // This shouldn't fail if data is valid
const total = items.reduce((sum, item) => sum + item.price, 0);
return total;
} catch (error) {
// Now what? You have bad data in your database.
logger.error('JSON parse failed', error);
return 0; // Silent failure
}
}
If JSON parsing is failing in production, that's a data integrity issue. You want it to fail loudly and alert you, not silently return 0. Use try/catch for external calls (database, API, file system, cache) and user input parsing. Don't use it to hide bugs in your own logic.
Don't catch just to log and rethrow everywhere
I see this pattern constantly in codebases:
async function fetchAndProcessUser(userId: string) {
try {
const user = await db.users.findById(userId);
try {
const profile = await externalApi.getProfile(user.id);
try {
await cache.set(`user:${userId}`, profile);
} catch (error) {
logger.error('Cache set failed', error);
throw error;
}
} catch (error) {
logger.error('Profile fetch failed', error);
throw error;
}
} catch (error) {
logger.error('User fetch failed', error);
throw error;
}
}
This is noise. Every catch logs and rethrows, so you're logging the same error three times with no additional context or recovery. The caller still has to handle it.
Instead, catch once at the boundary where you can actually do something:
async function fetchAndProcessUser(userId: string) {
try {
const user = await db.users.findById(userId);
const profile = await externalApi.getProfile(user.id);
await cache.set(`user:${userId}`, profile);
return profile;
} catch (error) {
// Log once with full context
logger.error('Failed to fetch and process user', {
userId,
error,
stack: error.stack
});
// Handle at this boundary
throw new HttpException('Could not load user profile', 503);
}
}
Single catch, single log, clear error response. The cache failure? Let it bubble up as part of the "processing failed" error. If you really need to handle cache failure separately, do that—but don't log and rethrow the exact same error.
Handle errors at boundaries
A boundary is where your system talks to the outside world: HTTP requests, database queries, third-party APIs, message queues. It's also where you return a response to the caller.
The pattern I use:
- Internal functions (business logic) don't catch—they let errors bubble up.
- Boundary functions catch and transform errors into a format the caller understands.
- Middleware or global handlers catch uncaught errors.
Example in a NestJS controller (the boundary):
@Post('orders')
async createOrder(@Body() createOrderDto: CreateOrderDto) {
try {
const order = await this.orderService.create(createOrderDto);
return { success: true, data: order };
} catch (error) {
if (error instanceof InsufficientFundsError) {
throw new HttpException('Insufficient funds', 402);
}
if (error instanceof InvalidProductError) {
throw new HttpException('Product not found', 404);
}
// Unknown errors get a 500
this.logger.error('Order creation failed', error);
throw new HttpException('Internal server error', 500);
}
}
The service (internal) doesn't catch:
async create(dto: CreateOrderDto) {
const product = await this.db.products.findById(dto.productId);
if (!product) throw new InvalidProductError();
const wallet = await this.wallet.getBalance(dto.userId);
if (wallet.balance < product.price) {
throw new InsufficientFundsError();
}
return await this.db.orders.create({ ...dto });
}
The service throws domain-specific errors. The controller catches and maps them to HTTP responses. The caller (front-end) knows what to do with a 402 vs. 404 vs. 500. No ambiguity, no surprise errors.
Always handle rejections from fire-and-forget calls
This is the pattern that causes production incidents. You start an async operation but don't await it because you want to return immediately:
// Bad: unhandled rejection
@Post('user')
async createUser(@Body() dto: CreateUserDto) {
const user = await this.db.users.create(dto);
// Send welcome email, but don't wait
this.emailService.sendWelcome(user.id); // Fire and forget
return user;
}
If sendWelcome rejects, the error is unhandled. Node.js won't crash by default (depending on your version), but the email won't send and you won't know.
Always attach a catch handler:
@Post('user')
async createUser(@Body() dto: CreateUserDto) {
const user = await this.db.users.create(dto);
// Fire and forget, but handle rejection
this.emailService.sendWelcome(user.id)
.catch(error => {
this.logger.error('Failed to send welcome email', { userId: user.id, error });
// Optional: retry, alert, etc.
});
return user;
}
Even better, offload it to a message queue (Kafka, Bull, etc.) so failures are retried automatically:
@Post('user')
async createUser(@Body() dto: CreateUserDto) {
const user = await this.db.users.create(dto);
// Queue the email job
await this.queue.sendWelcomeEmail.add({ userId: user.id });
return user;
}
The queue guarantees the job will be processed, with retries and dead-letter handling. This is how real systems do it.
Common mistakes
- Catching to suppress errors. If you catch an error and do nothing with it, you've hidden a bug. Either log it, handle it, or let it bubble up.
- Catching a general Error class. Use specific error types. Catch
NetworkError,ValidationError,NotFoundError—not justError. This lets you handle database connection timeouts differently from input validation failures. - Mixing async/await with .catch(). Pick one style and stick with it in a function. Mixing them is confusing. Use try/catch for async/await.
- Forgetting that Promise.all fails on first rejection. If one promise in the array rejects, the whole thing fails. Decide: do you want fail-fast or collect all errors? Use
Promise.allSettled()if you need the latter. - Not handling unhandled promise rejections globally. In Node.js, add a handler for process-level rejections:
typescript
process.on('unhandledRejection', (reason, promise) => { logger.error('Unhandled rejection', { reason, promise }); process.exit(1); // Or alert and continue based on your policy });
Key takeaways
- Wrap awaits that interact with external systems or user input—those have meaningful failure modes you can respond to.
- Catch once at the boundary where you understand the error and can handle it. Don't log and rethrow at every layer.
- Let internal functions throw domain-specific errors. Transform them to HTTP responses, database exceptions, or queue messages at the edge.
- Every fire-and-forget call needs a
.catch()handler or it should be queued in a system that retries automatically. - Use global unhandled rejection handlers to catch errors that escape your try/catch blocks.
- Be specific about error types. Catch
NetworkError, not genericError.






Comments
Loading comments…