Safeguard
AppSec

Uncaught Exceptions in JavaScript: Handling Them Without Hiding Bugs

A JavaScript uncaught exception is a thrown error that no catch block claims — and the worst response is a global handler that swallows it. Here is how to handle them in Node and the browser without hiding real bugs.

Yukti Singhal
Platform Engineer
7 min read

A JavaScript uncaught exception is an error that propagates all the way up the call stack without any try/catch claiming it — and the correct response is almost never to catch it globally and carry on, because at that point your program is in a state nobody designed. The instinct to install a top-level handler that logs and continues feels like defensive engineering. In practice it converts loud, diagnosable crashes into silent corruption: leaked connections, half-written records, and security checks that partially ran. This guide covers what actually happens when an uncaught exception in JavaScript fires, in Node and in the browser, and the handler patterns that preserve both uptime and truth.

What "Uncaught" Actually Means

When code throws, the engine unwinds the stack looking for the nearest enclosing catch. If none exists — across every frame back to the event loop tick that started the work — the exception is uncaught. What happens next is environment-specific:

  • Browsers report it to the console, fire the global error event, and kill that task — the page survives, but whatever that handler was mid-way through is abandoned.
  • Node.js prints the stack trace and terminates the process with exit code 1, unless a process.on("uncaughtException") listener exists.

The asynchronous sibling matters just as much: a rejected promise with no .catch() and no await inside a try is an unhandled rejection. Since Node 15, an unhandled rejection also crashes the process by default — a deliberate change from the old warn-and-continue behavior, made precisely because continuing past unknown failures proved harmful.

Why "Catch Everything and Continue" Is the Trap

Consider what your process might look like at the moment an unexpected exception reaches the top:

  • A database transaction is open and will now never commit or roll back until a timeout fires.
  • A mutex or connection-pool slot is held by a function that no longer exists.
  • An in-memory cache was updated but the write-behind never happened.
  • An authorization check ran, but the audit log line after it did not.

Node's own documentation is blunt about this: after an uncaught exception, the process is in an undefined state, and resuming normal work is unsafe. There is also a security angle — an attacker who finds an input that throws in a request handler can, in a "log and continue" setup, repeatedly push your process into that undefined state and probe what leaks. Exception paths that skip cleanup or checks are a recognized bug class, not just an ops nuisance.

The Correct Node.js Pattern: Log, Flush, Exit, Restart

Use the global hooks for what they are good at — final reporting — then get out:

process.on("uncaughtException", (err, origin) => {
  // Synchronous logging only: the process must not do new work.
  console.error(`Fatal (${origin}):`, err);
  metrics.flushSync?.();
  process.exit(1);
});

process.on("unhandledRejection", (reason) => {
  // Promote to the same fatal path; do not let these diverge.
  throw reason instanceof Error ? reason : new Error(String(reason));
});

Then let the supervisor provide the uptime: systemd, Docker's restart policy, Kubernetes, or PM2 restarts the process into a known-good state in milliseconds. Crash-and-restart is not a failure of engineering; it is the engineering. Erlang built a famously reliable ecosystem on exactly this "let it crash" philosophy.

For graceful degradation, pair the fatal handler with connection draining:

process.on("uncaughtException", async (err) => {
  console.error("Fatal:", err);
  server.close(() => process.exit(1));     // stop accepting, finish in-flight
  setTimeout(() => process.exit(1), 5000).unref(); // hard deadline
});

Five seconds of best-effort draining, then exit regardless. Never skip the deadline — a hung shutdown is worse than an abrupt one.

Handle Errors Where You Have Context

The global hook is the last line, not the strategy. Real handling happens where the code knows what failure means:

// Express: one error middleware, registered last
app.use((err, req, res, next) => {
  req.log.error({ err }, "request failed");
  const status = err.statusCode ?? 500;
  // Never leak stack traces or internals to clients
  res.status(status).json({ error: status === 500 ? "Internal error" : err.message });
});

Two rules do most of the work:

  1. Every await chain ends somewhere deliberate. In Express 4, async handler rejections bypass error middleware unless you wrap them (Express 5 forwards them automatically). A tiny wrapper closes the gap: const wrap = fn => (req, res, next) => fn(req, res, next).catch(next);
  2. Distinguish operational errors from bugs. A failed fetch, a validation error, a 404 — expected, handle locally, respond cleanly. An undefined property read three layers deep — a bug; let it crash and fix it. Catching bugs as if they were operational errors is how systems rot.

That client-facing rule in the middleware is worth repeating as a security control: stack traces in HTTP responses hand attackers file paths, dependency names, and versions. Leaked traces are exactly the kind of information-disclosure finding a DAST scan against your staging environment will surface before a pentester does.

The Browser Side

Browsers give you two global hooks, and their proper job is telemetry:

window.addEventListener("error", (e) => {
  reportToTelemetry({ message: e.message, stack: e.error?.stack });
});

window.addEventListener("unhandledrejection", (e) => {
  reportToTelemetry({ reason: String(e.reason) });
  e.preventDefault(); // suppress the console default only if you actually reported it
});

One caveat worth knowing: for scripts loaded cross-origin without CORS headers, the browser masks details and your handler sees only "Script error." — add crossorigin="anonymous" to script tags and proper CORS headers on the CDN to get real stacks. UI frameworks add their own layer (React error boundaries, Vue's errorCaptured) so a component crash degrades to a fallback UI instead of a blank page; use them for containment, and the global hooks for reporting.

Make Exception Paths Tested Paths

Untested error handling is decorative. The failure branches deserve the same coverage as the happy path: assert that a rejecting dependency produces a 500 without a stack trace in the body, that the process-level handler exits nonzero, that cleanup runs when a mid-transaction throw occurs. Our Node.js unit testing guide covers patterns for asserting on rejections and exit behavior without flaky process gymnastics. Error paths are where both reliability incidents and security bugs concentrate, and they are also where test coverage is reliably thinnest.

FAQ

What is an uncaught exception in JavaScript?

An error that propagates to the top of the stack without any try/catch handling it. In browsers it kills the current task and fires the global error event; in Node.js it terminates the process unless a global handler intervenes.

Should I use process.on("uncaughtException") to keep my server running?

Use it to log, flush telemetry, and exit — not to continue serving. After an unknown exception, held locks, open transactions, and partial state make continued execution unsafe. Let a supervisor restart the process into a clean state.

What is the difference between an uncaught exception and an unhandled rejection?

An uncaught exception is a synchronous throw nothing caught; an unhandled rejection is a promise that rejected with no handler attached. Since Node 15 both are fatal by default, and treating them identically (promote rejections to throws) keeps your failure handling consistent.

How do I stop error messages from leaking sensitive details?

Centralize response formatting in one error handler that returns generic messages for unexpected errors, log full details server-side only, and test for it. Include stack-trace leakage checks in your security scanning so a refactor that bypasses the handler gets caught.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.