How it works
Browsers. The HTML standard's report an exception algorithm creates an ErrorEvent and fires error at the global object with cancelable: true. The onerror handler is special: because the event is an ErrorEvent on a window or worker global, the handler gets five arguments, and a true return value cancels the event. If nothing cancels it, the browser may report to the developer console, and for a dedicated worker the error is also fired at the Worker object in the parent. reportError(e) runs the same algorithm.
Unhandled rejections go through HostPromiseRejectionTracker: the engine tells the host when a promise is rejected without a handler, and later when a handler is added. The host collects them in an "about-to-be-notified" list and, after the current task and its microtasks finish, fires unhandledrejection for each promise that is still unhandled. The event is cancelable too.
// Browser only, not run here
window.addEventListener('error', (event) => {
sendToMonitoring(event.error ?? event.message);
});
window.addEventListener('unhandledrejection', (event) => {
sendToMonitoring(event.reason);
event.preventDefault(); // suppress the default console error
});
Node.js. An exception that reaches the event loop is emitted as 'uncaughtException' if there is a listener, and otherwise printed with exit code 1. A rejection with no handler within a turn of the event loop emits 'unhandledRejection'. The default mode is throw: emit the event, and with no listener raise it as an uncaught exception. Other --unhandled-rejections modes are strict, warn, warn-with-error-code, and none. The exit codes can be checked directly:
import { spawnSync } from 'node:child_process';
const exitCode = (code) => spawnSync(process.execPath, ['-e', code]).status;
console.log(exitCode(`throw new Error('boom')`));
// → 1
console.log(exitCode(`process.on('uncaughtException', () => {}); throw new Error('boom')`));
// → 0
console.log(exitCode(`process.on('uncaughtException', () => { throw new Error('again'); }); throw new Error('boom')`));
// → 7
console.log(exitCode(`Promise.reject(new Error('lost'))`));
// → 1
console.log(exitCode(`process.on('unhandledRejection', () => {}); Promise.reject(new Error('lost'))`));
// → 0
Edge cases
- Rejections with a non-error reason (
Promise.reject('text')) still crash by default, but give you no stack. Reject with Error objects.
- Errors thrown during the ES module static loading phase of the entry point are always raised as uncaught exceptions, whatever the rejection mode.
- An uncaught error in a dedicated worker is also fired as an
error event at the Worker object in its owner.
- Node.js passes
origin ('uncaughtException' or 'unhandledRejection') to uncaughtException listeners, so a logger can tell a thrown error from a rejection that was promoted to one.
Real-world scenario
A Node.js service installs one module at startup. On either event it logs the error with its stack and request context, starts a bounded shutdown (stop accepting connections, close what it can), sets process.exitCode = 1, and lets a supervisor (systemd, Kubernetes, PM2) restart it. A browser app registers error and unhandledrejection once, forwards event.error and event.reason to its monitoring service, and still uses error boundaries and .catch for recoverable cases, so users see an error screen instead of a blank page.
Trade-offs
Global handlers are a safety net for reporting and cannot recover, because they have lost the call context that explains what failed. Aggressive crash-on-unhandled behavior surfaces bugs early and keeps state clean, but one stray rejection can take down a busy server. Most teams accept that and rely on restarts and graceful draining. The alternative of swallowing and continuing hides bugs, which the Node.js docs warn against.