Node.js Error Handling: Operational vs Programmer Errors
Error handling is where a lot of Node.js applications quietly fall apart. Not with a dramatic crash — with a request that hangs forever because a rejected Promise went unhandled, or a process that dies without explanation because an error was thrown somewhere no try/catch could reach. Node's asynchronous, single-threaded nature makes errors behave in ways that trip up people coming from other languages, and getting error handling right is less about writing more try/catch blocks than about understanding where errors surface and which ones you can actually recover from.
This is part of the Node.js runtime series. Here we cover the different ways errors propagate, the process-level safety nets, and the distinction that should drive your whole strategy.
Errors propagate differently depending on the pattern
The first thing to internalize is that how an error reaches you depends on which async pattern produced it, and mixing them up is how errors get lost.
Synchronous errors are the easy case. A throw in synchronous code is caught by an enclosing try/catch, exactly as you would expect:
try {
JSON.parse(invalidInput) // throws synchronously
} catch (err) {
handle(err)
}
Callback errors follow Node's error-first convention: the error arrives as the first argument, not by throwing. A try/catch around a callback-based call will not catch an error the callback reports, because the error is passed in, not thrown. You have to check the first argument every time:
fs.readFile("x", (err, data) => {
if (err) return handle(err) // the only way the error reaches you
})
Promise and async/await errors surface as rejections. With async/await, a rejected Promise makes the await throw, so try/catch works again — which is a big part of why async/await is the modern default. The trap is the Promise you forget to await or .catch. A rejection with no handler becomes an unhandled rejection, which does not go to any try/catch and, in current Node, crashes the process by default.
The through-line: try/catch catches synchronous throws and awaited rejections, but not error-first callbacks and not un-awaited Promises. Most "the error just vanished" bugs come from expecting try/catch to catch something it structurally cannot. Know which pattern you are in, and handle the error the way that pattern delivers it.
The process-level safety nets
Node gives you two global handlers for errors that escape everything else, and understanding their role — as last resorts, not primary handling — matters.
process.on("uncaughtException") fires when an error is thrown and nothing catches it anywhere. process.on("unhandledRejection") fires when a Promise rejects with no handler. It is tempting to treat these as a convenient catch-all: log the error, keep running, move on. That is a mistake. When one of these fires, your application is in an unknown state — an operation was interrupted at an arbitrary point, and whatever invariants it was maintaining may now be half-broken. Continuing to serve requests from a process in an undefined state can produce corrupted data and behavior far more confusing than a clean crash.
The accepted practice is to treat an uncaught exception as fatal: log it with full detail through your structured logger, and then let the process exit and be restarted fresh by your process manager or orchestrator. A clean restart returns you to a known-good state; limping along in a corrupted one does not. These handlers are for logging the thing you failed to handle and shutting down gracefully, not for pretending the error did not happen. Their real value is observability — knowing what escaped — plus a chance to flush logs and close connections before exiting.
The distinction that drives everything: operational vs programmer errors
The single most useful idea in Node error handling is separating two fundamentally different kinds of errors, because they call for opposite responses.
Operational errors are expected problems that a correct program will still encounter because the world is unreliable: a network request timed out, a database connection dropped, a file was not found, user input failed validation, a downstream service returned a 503. These are not bugs. They are runtime conditions you should anticipate and handle gracefully — retry the request, return a clean 400 to the user, fall back to a cache, surface a helpful message. Your application should recover from these and keep running, because they will happen in normal operation no matter how good your code is.
Programmer errors are bugs: calling a function with the wrong arguments, reading a property of undefined, a typo that throws a TypeError. These are not conditions to handle; they are mistakes in the code. Trying to "recover" from them is usually wrong, because you do not actually know what state the bug left things in. The right response to a programmer error is to let it crash the process, get logged, and be fixed — the fail-fast principle. Papering over a TypeError with a broad try/catch that swallows it just hides the bug and lets a corrupted process continue.
Once you hold this distinction, your strategy clarifies. Handle operational errors deliberately and specifically, close to where they occur, with real recovery logic. Let programmer errors fail fast and loud so you find and fix them. The mistake to avoid in both directions is a giant try/catch that catches everything and logs it the same way, which turns recoverable operational errors and fatal programmer bugs into the same undifferentiated "something went wrong" — losing the recovery you wanted for the first and hiding the second.
Practical guidelines
A few habits fall out of all this. Always attach a .catch (or wrap in try/catch with await) to every Promise; the un-awaited rejection is the most common lost error in Node. In an Express-style app, funnel operational errors to a central error-handling middleware so you produce consistent responses rather than scattering ad-hoc handling through every route — the thin-controller principle that keeps error logic in one place. Create custom error classes for your operational errors so you can distinguish a ValidationError from a NotFoundError and respond appropriately, using the type narrowing that instanceof gives you. And make sure everything that reaches a global handler is logged with enough context to actually debug it.
Done well, error handling is not defensive clutter spread through the codebase. It is a clear model: know how each async pattern delivers its errors, handle operational errors close to where they happen with real recovery, let programmer errors crash so you fix them, and keep the process-level handlers as logging-and-exit safety nets rather than catch-alls. That model, more than any amount of try/catch, is what keeps a Node service both resilient to the messy real world and honest about its own bugs.
Key takeaways
- How an error reaches you depends on the async pattern: synchronous throws and awaited rejections hit try/catch, but error-first callbacks and un-awaited Promises do not.
- An unhandled Promise rejection goes to no try/catch and crashes the process by default — always attach a catch or await.
- Treat uncaughtException and unhandledRejection as last-resort safety nets: log with full detail, then exit and restart, because the process is in an unknown state.
- Operational errors (timeouts, dropped connections, bad input) are expected and should be handled and recovered from gracefully.
- Programmer errors (bugs like reading a property of undefined) should fail fast and crash so you find and fix them, not be swallowed by a broad try/catch.
Frequently asked questions
Why does try/catch not catch some errors in Node.js?
Because try/catch only catches synchronous throws and rejections you await. Error-first callbacks deliver the error as an argument, not by throwing, and a Promise you never await or .catch rejects outside any try/catch. Most "the error vanished" bugs come from expecting try/catch to catch something it structurally cannot.
What should I do in an uncaughtException handler?
Log the error with full detail, flush logs, close connections, and let the process exit to be restarted fresh. When it fires, the app is in an unknown state where invariants may be half-broken, so continuing to serve requests risks corrupted data. Treat it as fatal, not as a catch-all to keep running.
What is the difference between operational and programmer errors?
Operational errors are expected runtime problems — timeouts, dropped connections, missing files, invalid input — that a correct program still hits and should recover from gracefully. Programmer errors are bugs like calling a function wrong or reading a property of undefined; the right response is to fail fast and fix them, not recover.
Should I use a global try/catch to handle all errors?
No. A giant catch-all that logs everything the same way turns recoverable operational errors and fatal programmer bugs into one undifferentiated "something went wrong," losing the recovery you wanted and hiding the bug. Handle operational errors specifically near where they occur, and let programmer errors crash.
Further reading
Explore more on

About the author
I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.