Let's say you own a Node service. How would you set up error handling across the whole thing?
A strong answer treats error handling as routing across Node's separate delivery channels, each with its own crash default, not just try/catch placement
So Node gives you a few different ways to handle errors, and I think you kind of want to use all of them together. For synchronous stuff and async/await you use try/catch. Promise chains get a .catch at the end. The older callback APIs are error-first, so you check the first argument before you touch the result. And then streams and EventEmitters emit 'error' events, so you attach a listener for those and the process stays up. On top of that, most teams register handlers for uncaughtException and unhandledRejection as kind of a safety net. In something like Express you'd centralize the rest in an error-handling middleware that logs the error and sends back a clean 500. Generally I try to catch errors close to where they happen and log enough context to debug later. The main thing is one bad request should never take down the whole server.
The first thing I'd say is Node doesn't have one error channel, it has four, and they don't compose. A try/catch covers synchronous frames plus whatever you await, and that's it. Throw inside a callback and that code is running on a fresh stack rooted in the event loop. Your calling frame returned ages ago, so the surrounding try block is just gone. Then you've got error-first callbacks, promise rejections, and 'error' events, and each of those carries its own crash default. An unobserved rejection kills the process, that's been the default since Node 15. And emit('error') with nobody listening throws synchronously from inside emit itself, which surprises people.
So what I actually do is convert at the boundary. util.promisify or the fs/promises-style APIs for callback code, stream.pipeline and events.once to turn emitter failures into rejections. Once you've done that, almost every failure in the system is an awaited rejection flowing toward a handful of catch points, the per-request wrapper, the job runner, the queue consumer loop. And inside that funnel, each layer wraps with new Error(msg, { cause }) so the classification survives the trip up.
But some things won't convert. Server sockets, connection pools, anything long-lived, there's no single await point for those. So the rule becomes ownership, whoever creates an emitter attaches its 'error' listener in the same breath. And the process-level handlers stay in the design, but as a last line that reports and exits. Never as a place where you keep serving requests.
Honestly, at this level the design is the easy part. What matters is enforcement and policy, because the whole thing decays without both. The classic outage, and I've lived this one, is a mixed codebase where one raw .pipe chain or one pool socket without a listener throws Unhandled 'error' event at 3am and takes a replica down mid-request. Everything else got converted, that one didn't.
The audit is pretty mechanical. You lint for bare .pipe, for new EventEmitter without an 'error' handler right next to it, and for catch blocks that swallow without rethrowing or classifying. Then measurement. I export counters from process.on('uncaughtExceptionMonitor') and events.errorMonitor so crashes are attributable before the process dies, flush that telemetry synchronously in the exit path, and track crash rate by exit reason instead of one big restart count. Request id goes through AsyncLocalStorage so every error the funnel catches lands in logs with the request that caused it.
And then there's the policy argument, which you have to actually win. Programmer errors crash the process and a supervisor restarts it with backoff. Operational errors get handled at the boundary, retries, shedding, a mapped response. Teams push back because crashing looks worse on the availability dashboards. My defense is state integrity. A process that keeps going past a broken invariant gives you the incidents you cannot reproduce, corrupted cache entries, pool checkouts that never come back, writes applied once and a half. Sure, the funnel costs you some per-callsite nuance and a little stack depth. What it buys is failure behavior you can predict, reproduce in a test, and actually explain during the postmortem.
- People love to say 'just wrap everything in try/catch', and it falls apart the moment an error gets thrown inside a callback, because it never reaches the surrounding try block.
- A lot of candidates make the global handler the whole plan, something like 'we have process.on('uncaughtException') that logs and keeps the server running', and stop there.
- Plenty of answers treat .catch, try/catch, and 'error' listeners like they're interchangeable, when each one is its own channel with its own crash default.