There's a certain artistry to writing asynchronous code in modern Node.js. We've journeyed far from the callback pyramids of old, navigated the limbo of Promise.then().catch() chains, and finally found what felt like the promised land: async/await.
It reads like synchronous code. It's beautiful. It's clean. We can almost forget we're dealing with the non-blocking, event-driven heart of Node.
But this beauty is a siren's call. It tempts us to ignore the jagged rocks of reality lurking just beneath the surface—the rocks of errors.
My fellow senior developer, let's take a journey. Let's not treat this as a lecture, but as a critique of an artwork we're all composing: our codebase.
Act I: The Illusion of Control
When we first embraced async/await, we were diligent. We built sturdy, if a little clumsy, fortresses around our promises.
// The Diligent Novice's Art
const fetchUserData = async (userId) => {
try {
const user = await User.findById(userId);
const posts = await Post.find({ author: userId });
const avatar = await uploadAvatar(user.avatarUrl); // A potential failure point
return { user, posts, avatar };
} catch (error) {
console.error('Oh no! Something went wrong:', error);
// But what *specifically* went wrong? And what do we do now?
}
};
This code has good intentions. It's defensive. But it's a blunt instrument. An error in User.findById is treated with the same brush as an error in uploadAvatar. The user gets a generic "Something went wrong," and we, the developers, get a cryptic log line. Our application state is a mystery. Did the user get created without posts? Was the avatar upload half-complete?
This is the first stage of our journey: recognizing that a single, monolithic try/catch is like using a single color to paint an entire landscape. It captures the scene but loses all the detail.
Act II: The Descent into Silence
Then, we get clever. We think, "I'm a senior dev. I know this function is mostly safe. That thirdPartyAPI.call() is robust, I don't need to clutter the code."
So we write this:
// The Siren's Song of "Clean Code"
const processOrder = async (orderId) => {
const order = await Order.findById(orderId); // No try/catch
order.status = 'processing';
await order.save(); // No try/catch
// This is the bomb that never ticks... until it does.
await thirdPartyShippingAPI.notify(order); // No try/catch
await EmailService.sendConfirmation(order.userEmail, order); // No try/catch
logger.info(`Order ${orderId} processed successfully`);
};
From a distance, this code is a masterpiece of minimalism. It's pure logic, unburdened by the messiness of reality. But it's a house of cards.
When thirdPartyShippingAPI.notify() fails—and it will—what happens?
- The promise rejects.
- The
processOrderfunction throws. - The error bubbles up to the event loop.
- If unhandled, it crashes your Node.js process.
A single, transient network blip in a non-critical service can now take down your entire application. This isn't clean code; it's a denial of the distributed, unreliable nature of the systems we build. We've painted over the cracks in the canvas, hoping no one will notice.
Interlude: The Art of Intentional Failure
A true master doesn't ignore failure; they incorporate it into their design. Error handling isn't a defensive chore; it's a core feature of your application's logic. It's the shadows in a painting that give the light meaning.
We need to move from ignoring errors to orchestrating our response to them.
Act III: The Master's Palette - Composition and Strategy
As senior developers, our goal isn't to just prevent crashes. It's to build systems that are resilient and observable. This requires a more nuanced palette of techniques.
Strategy 1: The Functional Wrapper (handle)
Instead of repetitive try/catch blocks, we can compose our async functions with a wrapper that structures the outcome.
// A stroke of functional genius
const handle = (promise) => promise.then((data) => [null, data]).catch((error) => [error, null]);
const updateUserProfile = async (userId, updates) => {
let error, user, avatar;
[error, user] = await handle(User.findByIdAndUpdate(userId, updates, { new: true }));
if (error) {
await logError('DB_UPDATE_FAILED', error, { userId });
throw new ApplicationError('Failed to update user profile');
}
if (updates.avatarUrl) {
[error, avatar] = await handle(ImageProcessor.uploadAndResize(updates.avatarUrl));
if (error) {
// We failed to upload the avatar, but the user was updated!
// This is a partial failure. How do we handle it?
await logError('AVATAR_UPLOAD_FAILED', error, { userId });
await EmailService.sendAlert(user.email, 'Your profile was updated, but we failed to process your new avatar.');
// We do NOT throw. The core operation was successful.
}
}
return { user, avatar };
};
This approach gives us fine-grained control. We can handle errors where they occur, make decisions about partial success, and never lose context.
Strategy 2: The catch Method - A Deliberate Brushstroke
Sometimes, you can recover from an error immediately. In such cases, attaching a .catch() to a non-critical promise is an elegant solution.
const fulfillOrder = async (order) => {
// Critical: Must not fail.
await InventoryService.decrementStock(order.lineItems);
// Non-critical: Should not block fulfillment.
Analytics.track('order_fulfilled', order)
.catch((analyticsError) => {
// We log it for later investigation, but the order continues.
logger.warn('Analytics flush failed', { error: analyticsError, orderId: order.id });
});
// Also non-critical: Fire-and-forget email.
EmailService.sendReceipt(order.userEmail, order)
.catch((emailError) => logger.warn('Receipt email failed', { error: emailError }));
// The main function fulfills regardless of the non-critical tasks.
await markOrderAsFulfilled(order.id);
};
This is intentional. We are declaring, "This operation is best-effort. Its failure should not impact the core journey."
Strategy 3: The Architectural Frame - Express Error-Handling Middleware
At the boundaries of your application, you need a frame to catch everything that slips through. In an Express.js app, this is your final safety net.
// The final, unifying frame for your artwork
app.use(async (err, req, res, next) => {
// Log the error with a structured logger
logger.error('Unhandled Error', {
message: err.message,
stack: err.stack,
path: req.path,
method: req.method,
userId: req.user?.id
});
// Check if it's an error we expect
if (err instanceof ValidationError) {
return res.status(400).json({ error: 'Invalid input', details: err.details });
}
if (err instanceof AuthenticationError) {
return res.status(401).json({ error: 'Please log in' });
}
// Default: Don't leak internals!
res.status(500).json({ error: 'An internal server error occurred' });
});
This middleware is the gallery guard. It ensures that no unhandled error, no matter how deep in your async call stack, ever brings down the server or returns an unformatted stack trace to the user.
The Finished Masterpiece
Ignoring error handling in async/await is like an artist ignoring the laws of perspective. You might create something that looks interesting for a moment, but it will never stand the test of time.
The true masterpiece—the codebase that is resilient, observable, and maintainable—is one where error handling is an integral part of the composition. It's not an afterthought; it's the careful application of shadow and light, the strategic use of a functional wrapper, the deliberate suppression of a non-critical task, and the strong, reliable frame of a top-level handler.
So, the next time you write await, pause. Ask yourself: "What is the story of failure here? And how shall my code tell it?"
Happy coding, artist.
Top comments (0)