Node.js95 min total · 14 parts
Node.js Fundamentals: The Runtime, the Event Loop, and Building Real APIs
Part 10 of 14 · ~4 min
Error Handling Patterns
try/catch and the gap a detached Promise falls through
async function processWebhook(orderId, payload) {
try {
const order = await saveOrder(payload);
await notifyOps(order);
} catch (err) {
console.error("Webhook processing failed:", err);
}
}
Wrapping an await in try/catch behaves indistinguishably from wrapping a synchronous throw — whatever rejects surfaces right where the await sits, as if it had been thrown on that exact line. The catch only covers ground it's actually standing on, though. Start a Promise inside that same block and don't await it, and by the time it eventually rejects, this function may well have already returned — that rejection is happening completely outside anything the try/catch can see:
async function processWebhook(orderId, payload) {
try {
const order = await saveOrder(payload);
notifyOps(order); // missing await! this Promise is now detached
} catch (err) {
console.error("Webhook processing failed:", err); // never sees a notifyOps() rejection
}
}
All that catch block can actually observe is a saveOrder failure. notifyOps failing is a separate, unconnected event happening on its own orphaned Promise — it'll surface as an unhandledRejection somewhere else, or not at all, but never here. There's really only one fix, and it's the same one from two chapters back wearing a different hat: put the await back.
Centralized error handling, and why the status code isn't cosmetic
Instead of formatting an error response inside every single route, each one just hands off via next(err) and lets a single error-handling middleware do that job everywhere:
app.post("/webhooks/meridian", verifyMeridianSignature, async (req, res, next) => {
try {
const order = await saveOrder(req.body);
res.status(200).json({ received: true });
} catch (err) {
next(err); // hand off — don't format the response here too
}
});
app.use((err, req, res, next) => {
const status = err.status || 500;
res.status(status).json({ error: status < 500 ? err.message : "Internal server error" });
});
For billhook specifically, that status code is load-bearing, not a courtesy: Meridian Pay's documented retry behavior is to retry a webhook delivery on a 5xx response or a timeout, and to mark it permanently failed — no retry, ever — on a 4xx. Return 500 for a malformed payload that will never parse correctly no matter how many times Meridian resends it, and you've committed to Meridian hammering that same broken delivery forever. Return 400 for a transient database connection hiccup, and you've told Meridian to give up on a payment confirmation that would have succeeded on the very next attempt. Getting this mapping backwards is invisible in a demo and expensive in production, which is exactly why it deserves more attention than "throw the error and see what happens."
And for a genuine 5xx, the response body above deliberately hides the real error message from the caller: leaking it risks exposing stack traces, library names, sometimes query fragments to whatever's on the other end of that webhook. A 4xx, on the other hand, can be as specific as you like — whatever it's describing is a problem with that particular request, not with billhook's internals.
Operational errors vs. programmer errors
| Operational errors | Programmer errors | |
|---|---|---|
Examples for billhook | An invalid or unparseable webhook payload, Meridian's API timing out, the DB connection dropping mid-query | Reading order.meridianId before checking order came back non-null, an off-by-one in pagination, a missed await |
| What they represent | Ordinary friction a healthy system is built to absorb | Something the code got wrong |
| Right response | Catch it, hand the client an appropriate response (400, 503, occasionally a retry), and keep running | Write it down with everything you know about the failure, then let the process die — trusting further requests to a program that might be in a state nobody planned for is the actual risk |
This isn't just a labeling exercise — it should genuinely steer how the code responds. A webhook body that fails validation was never a bug in the first place; Meridian (or anyone probing that endpoint) is fully entitled to send something malformed, and turning it into a clean 400 is the complete, correct response. A programmer error is a different animal entirely — something the code took for granted turned out not to hold, somewhere earlier in the chain. Pretend nothing happened and keep going, and everything that runs afterward — this request, or a later one, if anything got left in a half-updated state — is now executing against a program whose assumptions no longer match reality. That's the real justification behind the deployment chapter's process manager restarting a crashed process after a programmer error — and exactly why applying that same "just restart it" reflex to an everyday malformed webhook would be actively counterproductive.