Node.js Uncaught Exceptions and Process Crash Anatomy: What Actually Kills Your App

Half your Node.js crashes trace back to three root causes — and none of them announce themselves cleanly. A Promise nobody .catch()-ed. An error thrown inside a callback where try/catch can’t reach it. An EventEmitter that fired ‘error’ with no listener.

The process dies, exit code 1 appears in your logs, and the stack trace points somewhere unhelpful. Node.js uncaught exception handling exists precisely for this failure surface — and most production codebases bolt it on as an afterthought.

Understanding node.js error handling at the process level means knowing three things: where errors originate, how Node routes them internally, and what happens in the process’s final seconds before termination. An unhandled promise rejection in Node 15+ crashes your app identically to an uncaught synchronous exception.

An async error inside setTimeout reaches uncaughtException but bypasses every try/catch you wrote. These aren’t edge cases — they’re default runtime behavior.


TL;DR: Quick Takeaways

  • uncaughtException and unhandledRejection are fundamentally different events — conflating them is where most error handling bugs start.
  • Since Node.js 15, an unhandled promise rejection crashes the process with exit code 1 — no more silent swallowing.
  • Continuing execution after uncaughtException is a recognized anti-pattern; the process state is undefined from that point forward.
  • Graceful shutdown means draining inflight work, closing DB connections, and forcing exit after a timeout — not just calling process.exit().

What Actually Kills a Node.js Process

The Node.js process lifecycle has several well-defined termination paths. A synchronous exception propagating to the top of the call stack without a handler. A rejected promise with no .catch() and no surrounding try/catch in an async function. An EventEmitter that emits 'error' with no listener attached. An explicit process.exit() call. An uncaught fatal signal from the OS. Each path has a different behavior, different exit code, and different visibility in your process manager logs. The silent killer category — what kills node.js process silently — is almost always an unhandled rejection in older Node versions (pre-15), where the process would log a deprecation warning and keep running in a corrupted state.

uncaughtException vs unhandledRejection: Not the Same Thing

These two events are frequently treated as interchangeable. They are not. uncaughtException fires when a synchronous error or an error thrown inside a callback propagates to the top of the event loop without being caught. unhandledRejection fires when a Promise is rejected and no rejection handler is attached within the same microtask queue turn. The distinction matters because their recovery semantics are completely different. An uncaughtException means something in the synchronous call stack broke. An unhandledRejection means a Promise chain was left open — and depending on your Node version, the process may or may not die because of it.

How uncaughtException Fires — and the setTimeout Trap

When an error is thrown synchronously and nothing catches it, Node emits 'uncaughtException' on the process object before terminating. If you attach a listener, the default behavior (crash) is suppressed. That sounds useful. It’s mostly a trap. The problem is that by the time uncaughtException fires, the call stack that threw is already unwound. You have no context, no meaningful recovery path, and the process internal state — file descriptors, DB connection pools, in-flight HTTP requests — may be partially broken.

// what triggers uncaughtException
process.on('uncaughtException', (err, origin) => {
 // origin: 'uncaughtException' or 'unhandledRejection'
 fs.writeSync(process.stderr.fd, `Caught: ${err.message}n`);
 process.exit(1); // correct: exit immediately, let PM2 restart
});

// sync throw — caught
throw new Error('boom');

// async callback throw — also caught
setTimeout(() => {
 throw new Error('async boom'); // fires uncaughtException too
}, 100);

// Promise rejection — NOT caught by uncaughtException
Promise.reject(new Error('promise boom')); // goes to unhandledRejection

The critical takeaway from the code above: a throw inside a setTimeout callback does reach uncaughtException — the error fires from within the event loop callback, not from an async Promise chain. This surprises people. It means how to catch error in setTimeout node.js is actually answered by process.on('uncaughtException'), not by wrapping the setTimeout call in try/catch (that catches nothing, the callback runs later). The architectural conclusion: try/catch only guards synchronous code within the same call frame.

Deep Dive
Nodejs event loop lag

Node.js Event Loop Lag in Production Systems Your Node.js server is alive. CPU at 12%, memory stable, no errors. But API response times quietly climb from 40ms to 400ms over a busy afternoon. No crash,...

unhandledRejection and the Node 15 Breaking Change

Before Node.js 15, an unhandled promise rejection emitted a deprecation warning and execution continued. That behavior masked bugs for years — production services running with corrupted state, returning wrong data, silently failing DB writes. Node 15 changed this: unhandled rejections now crash the process with exit code 1 by default, equivalent to --unhandled-rejections=throw. If you’re on Node 14 and migrating, this is the node 15 behavior change that breaks the most apps. The fix isn’t to suppress rejections — it’s to handle them explicitly, either with .catch() or try/catch inside async functions.

process.on('unhandledRejection', (reason, promise) => {
 console.error('Unhandled rejection at:', promise, 'reason:', reason);
 // In production: log to Sentry/Pino, then exit
 process.exit(1);
});

// --unhandled-rejections=strict flag does the same at the CLI level
// node --unhandled-rejections=strict server.js

The unhandledRejection handler receives the rejection reason and the Promise reference. That Promise reference is your debugging anchor — log it with full stack trace. In production, pair this with structured logging (Pino or Winston) and ship the event to an error tracker before calling process.exit(1). Swallowing it here is the same mistake as not handling it at all.

Process Crash Anatomy: What Node Does Internally

When Node.js decides to terminate, it doesn’t just stop. The sequence matters for understanding what your process manager actually sees and what data you can still recover. First, the 'exit' event fires on the process object — synchronous listeners only, no async operations will complete here. Then the V8 heap is torn down. Then the libuv event loop shuts down, closing handles. Then the OS gets the exit code. The process.on('exit') callback is your last synchronous hook — you can write to stderr, flush a sync log, but you cannot await a database flush. Anything async here silently gets skipped.

Exit Codes, stderr, and What Your Process Manager Sees

Exit codes are how your process communicates its fate to the outside world. process.exit(0) means clean termination — PM2, systemd, Kubernetes will treat this as intentional and may not restart. process.exit(1) signals an error — most process managers restart on non-zero exit codes. The process.exit(1) vs process.exit(0) difference is not just semantic: it controls your restart policy. Beyond 0 and 1, Node uses specific codes: 5 is a fatal V8 error, 9 is a SIGKILL from the OS, 11 is a segfault. These appear in dmesg and your container logs. When you see exit code 137 in Docker, that’s 128 + SIGKILL — the container OOM killer terminated your process, not a JS exception.

Exit Code Cause Process Manager Behavior
0 Clean exit / process.exit(0) Usually no restart (intentional)
1 Uncaught exception / process.exit(1) Restart (error condition)
5 Fatal V8 error Restart
11 Segmentation fault (native addon) Restart
137 SIGKILL (OOM killer) Restart, check memory limits

async/await and the try/catch Trap

The most common source of unhandled rejections in modern Node.js codebases is async functions that throw without a surrounding try/catch and without a caller that handles the returned Promise. This isn’t exotic — it’s the default behavior of async/await. When an async function throws, it returns a rejected Promise. If the caller doesn’t await it (or awaits it without try/catch), the rejection propagates silently until it hits unhandledRejection. In Express.js specifically, async route handlers that throw will bypass Express’s error middleware entirely in Express 4 — the process gets an unhandledRejection, not a 500 response.

// Classic Express 4 async trap
app.get('/data', async (req, res) => {
 const result = await db.query('SELECT * FROM things'); // throws
 res.json(result); // never reached
 // Error escapes Express error middleware — goes to unhandledRejection
});

// Correct pattern: wrap or use express-async-errors
app.get('/data', async (req, res, next) => {
 try {
 const result = await db.query('SELECT * FROM things');
 res.json(result);
 } catch (err) {
 next(err); // correctly routes to Express error middleware
 }
});

The express-async-errors package patches Express to forward async throws to next(err) automatically — one require at app entry and the trap disappears. In native Express 5, this is fixed at the framework level. The node.js express error handling middleware only works if errors actually reach it — an async throw that bypasses next(err) is invisible to it. This is why production Express 4 apps with async routes often show unhandledRejection events rather than proper 500 error logs.

Technical Reference
What V8 Serialization Actually...

V8 Serialization: When JSON.stringify Finally Lets You Down V8 serialization isn't something most Node.js developers reach for on day one. You've got JSON.stringify, it works, life goes on. Then one day you're passing a Map...

Graceful Shutdown: The Right Way to Die

A Node.js process that handles SIGTERM and SIGINT properly is worth significantly more in production than one that just crashes. Kubernetes sends SIGTERM before killing a pod. PM2 sends SIGTERM before restarting. A 10-30 second window opens between the signal and the forced SIGKILL. Inside that window, a well-written process can drain its HTTP server, finish in-flight requests, flush buffered logs, and close database connections cleanly. Most Node.js apps ignore this window entirely, which is how you get dropped requests during deploys and partially-written database records.

Closing DB Connections, server.close(), and the Force-Exit Timeout

The pattern for node.js graceful shutdown on error is consistent across frameworks: stop accepting new connections, finish inflight work, release resources, exit explicitly. The force-exit timeout is non-negotiable — without it, a hung DB connection or a stalled request will prevent the process from ever exiting, blocking your deploy indefinitely. In production, 10 seconds is a reasonable upper bound; anything longer and your orchestrator will kill the process with SIGKILL anyway.

let server;

const shutdown = async (signal) => {
 console.log(`Received ${signal}, starting graceful shutdown`);

 // Stop accepting new HTTP connections
 server.close(() => {
 console.log('HTTP server closed');
 });

 // Close DB pool — this waits for active queries to finish
 await db.pool.end();

 // Release Redis/message queue connections
 await redisClient.quit();

 console.log('All connections closed, exiting');
 process.exit(0);
};

// Force exit if graceful shutdown hangs
setTimeout(() => {
 console.error('Graceful shutdown timed out, forcing exit');
 process.exit(1);
}, 10_000).unref(); // .unref() so this timer doesn't block the event loop itself

process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));

server = app.listen(3000);

The .unref() on the timeout is subtle but important — without it, the timeout itself keeps the event loop alive, which defeats the purpose. The SIGTERM/SIGINT graceful shutdown pattern above covers the standard orchestration signals. In PM2, set "kill_timeout": 10000 in your ecosystem config to align the force-kill window with your shutdown timeout.

Production Patterns: Logging, Monitoring, Restart Strategy

Error handling in production is only as good as your observability. Catching exceptions without shipping them to a structured log or error tracker means you’re flying blind between restarts. The standard stack for how to log errors in node.js production: Pino for structured JSON logging (up to 5× faster than Winston in high-throughput services), Sentry for error aggregation with stack traces and release tracking, and PM2 or systemd for restart policy. PM2’s --max-memory-restart flag handles the memory leak scenario — process exceeds threshold, PM2 restarts it, error gets logged. That’s crash-only design in practice: don’t try to fix the leak at runtime, just restart cleanly and fix the leak in code.

Operational errors (network timeout, DB connection refused, file not found) are recoverable and should be handled at the point of failure with proper logging and user-facing error responses. Programmer errors (TypeError on undefined, assertion failures, logic bugs) are not recoverable — the correct response is to log and exit immediately, letting the process manager restart into a clean state. This operational error vs programmer error distinction from Joyent’s original Node.js error handling guide still holds. The uncaughtExceptionMonitor event (added in Node 13) lets you observe uncaught exceptions without suppressing the crash — useful for error tracking hooks that need to run before the process dies without the risk of accidentally continuing execution.

FAQ

Is it safe to continue after uncaughtException in Node.js?

No — and this is the most common anti-pattern in junior Node.js codebases. When uncaughtException fires, the call stack that caused the error is already gone. The application may have open file handles in an inconsistent state, a DB transaction that was never committed or rolled back, or middleware that executed halfway. The Node.js documentation explicitly says the process should be restarted after logging. The correct pattern is: log the error with full context, call process.exit(1), and rely on your process manager to restart. Running in a degraded state after an uncaught exception causes data corruption bugs that are very hard to reproduce.

Worth Reading
Node.js Async Hooks Explained

Node.js Async Hooks Deep Dive: When Your Request ID Vanishes Mid-Fligh You've traced the bug for two hours. The request ID is there at the controller, gone by the time you hit the database logger....

What changed in Node.js 15 for unhandled promise rejections?

Before Node 15, unhandled promise rejections triggered a DeprecationWarning and execution continued — the process didn’t crash. From Node 15 onward, the default behavior changed to throw mode: an unhandled rejection terminates the process with exit code 1, the same as an uncaught synchronous exception. This was a deliberate breaking change to force codebases to handle rejections explicitly. If you’re migrating from Node 14 and apps are suddenly crashing on deploy, unhandled rejections are the first place to check. The --unhandled-rejections=warn flag can temporarily restore old behavior, but it’s a migration crutch, not a fix.

Can try/catch catch async errors in callbacks?

No, and this is one of the most persistent misunderstandings in Node.js error handling. A try/catch block only catches errors that are thrown synchronously within the same call frame. If you wrap setTimeout(callback, 100) in try/catch, the try/catch completes before the callback ever runs — the callback executes in a later event loop iteration, completely outside the original try/catch scope. The same applies to EventEmitter callbacks, stream data handlers, and any other async callback pattern. For callbacks, you need error-first callback convention or convert to Promises and use async/await with try/catch. The only way to catch errors from setTimeout callbacks globally is process.on('uncaughtException').

What is the difference between uncaughtException and unhandledRejection?

uncaughtException handles synchronous errors and errors thrown in non-Promise async callbacks (timers, EventEmitter listeners) that were never caught. unhandledRejection handles Promise rejections that had no .catch() or weren’t awaited inside a try/catch. They are separate events on the process object, fire at different points in the event loop, and have different recovery semantics. The origin parameter on uncaughtException in newer Node versions will tell you whether it was triggered by a sync exception or by an unhandled rejection that was routed through the uncaught exception mechanism.

What happens to open DB connections when Node crashes?

Without graceful shutdown, DB connections are abandoned. The DB server keeps them open until its own timeout fires — typically 30 seconds to several minutes depending on configuration. In high-traffic services, a crash loop (crash → restart → crash) can exhaust the DB connection pool before each new instance can establish connections. PostgreSQL’s default max_connections is 100; a crashlooping Node service can consume all of them in under a minute with a 10-connection pool per instance and 10 instances. The mitigation is a combination of graceful shutdown (closes connections cleanly) and connection pooling via PgBouncer or RDS Proxy (limits connection consumption per app instance).

Should I use uncaughtException as a last resort or never?

As a last resort for logging — never for recovery. The only legitimate use of process.on('uncaughtException') in production code is to ensure errors get shipped to your error tracker before the process terminates. You attach the listener, log the error with context, then call process.exit(1). The uncaughtExceptionMonitor event is a cleaner alternative for this use case — it fires before the crash and doesn’t suppress termination even if you don’t call process.exit() yourself. Never use uncaughtException to catch expected errors and continue running — that work belongs in try/catch blocks, Promise chains, and domain-specific error boundaries.

Author: krun.pro engineering — written from production Node.js deployments, not from documentation.

Written by:

Source Category: JS Runtime Deep Dive