On this page

Global Error Handling: uncaughtException and unhandledRejection

What These Two Events Are

Most errors should be caught where they happen, with try/catch or .catch(). Some errors slip through. Node.js has two last-chance events on the process object for them:

EventFires whenExample
uncaughtExceptionA synchronous error is thrown and nothing catches itorder.id when order is null, inside a setTimeout callback
unhandledRejectionA promise rejects and nothing handles it (no await in try/catch, no .catch())An async payment call fails and nobody awaited it

Think of a safety net under a trapeze. Performers should catch each other (your try/catch). The net only stops a fall from being fatal. Nobody keeps performing after landing in it; the show stops, and the act restarts safely.

1Error thrown
2 → caught by try/catch / .catch()? → handled normally ✓
3 → Express route? → error middleware → 500 response ✓
4 → nothing caught it
5 → uncaughtException / unhandledRejection
6 → log it → shut down cleanly → exit(1)
7 → PM2 / Docker / Kubernetes restarts the app

How Each One Happens

// 1. uncaughtException: sync error with no try/catch around it
setTimeout(function () {
const order = null;
console.log(order.id); // TypeError: Cannot read properties of null
}, 1000);
// 2. unhandledRejection: promise fails, nobody handles it
async function chargeWallet(userId, amount) {
throw new Error('Payment gateway timeout');
}
chargeWallet(42, 300); // no await, no .catch()
// 3. EventEmitter 'error' with no listener also becomes uncaughtException
const fs = require('fs');
const stream = fs.createReadStream('missing-menu.csv');
// no stream.on('error', ...) -> ENOENT crashes the process

A try/catch around a setTimeout call does not catch errors thrown inside its callback. The callback runs later, after the try block has finished.

Default behaviour: without handlers, both crash the process with exit code 1. Unhandled rejections crash by default since Node.js 15. Before that, they only printed a warning, and many apps kept running in a broken state.

Adding the Handlers

The handler's job is not to recover. After an unexpected error, the app's state may be broken: a half-finished order, a leaked connection, a lock never released. The safe sequence is:

  1. Log the error with full details.
  2. Stop accepting new requests.
  3. Finish requests already in progress and close database connections.
  4. Exit with code 1, and let the process manager start a fresh copy.
const app = require('./app');
const mongoose = require('mongoose');
const { pool } = require('./db');
const logger = require('./logger');
const server = app.listen(3000, function () {
logger.info('Food delivery API listening on port 3000');
});
let shuttingDown = false;
function shutdown(reason, exitCode, err) {
if (shuttingDown) return; // ignore errors during shutdown
shuttingDown = true;
if (err) logger.error({ err: err }, 'Fatal: ' + reason);
else logger.info('Shutting down: ' + reason);
// force exit if cleanup hangs
setTimeout(function () { process.exit(exitCode); }, 10000).unref();
server.close(async function () { // stop new requests, finish open ones
try {
await mongoose.connection.close();
await pool.end();
} finally {
process.exit(exitCode);
}
});
}
process.on('uncaughtException', function (err, origin) {
shutdown('uncaughtException (' + origin + ')', 1, err);
});
process.on('unhandledRejection', function (reason) {
const err = reason instanceof Error ? reason : new Error(String(reason));
shutdown('unhandledRejection', 1, err);
});
// normal stop from Docker / Kubernetes / PM2
process.on('SIGTERM', function () { shutdown('SIGTERM', 0); });
process.on('SIGINT', function () { shutdown('SIGINT', 0); });

Register these handlers once, at startup, in the file that starts the server.

unhandledRejection passes a reason, which can be anything (reject('oops') passes a string). Wrapping it in an Error keeps a stack trace in the logs.

Catch Errors Before They Reach the Net

In an Express app, most errors should end in the error-handling middleware (a middleware with four arguments: err, req, res, next), which sends a clean response.

Express 4 does not catch rejected promises from async route handlers. The error becomes an unhandledRejection and the request hangs. Wrap async routes, or use Express 5, which forwards rejected promises to the error middleware automatically.

const express = require('express');
const app = express();
// expected errors carry a status code
class AppError extends Error {
constructor(message, status) {
super(message);
this.status = status;
this.isOperational = true;
}
}
// Express 4: pass async errors to next()
const asyncHandler = function (fn) {
return function (req, res, next) {
Promise.resolve(fn(req, res, next)).catch(next);
};
};
app.get('/orders/:id', asyncHandler(async function (req, res) {
const order = await Order.findOne({ _id: req.params.id, userId: req.user.id });
if (!order) throw new AppError('Order not found', 404);
res.json(order);
}));
// central error middleware: registered last
app.use(function (err, req, res, next) {
if (err.isOperational) {
return res.status(err.status).json({ message: err.message });
}
logger.error({ err: err, path: req.path }, 'Unexpected error');
res.status(500).json({ message: 'Something went wrong' });
});
module.exports = app;

Operational vs Programmer Errors

Operational errorsProgrammer errors (bugs)
WhatExpected problems at runtimeMistakes in the code
ExamplesOrder not found, invalid coupon, payment gateway timeout, DB connection droppedReading a property of undefined, wrong argument type, forgotten await
Handle byCatch, respond with 4xx/5xx, retry if usefulFix the code; at runtime, log and restart
Usually ends inExpress error middlewareuncaughtException / unhandledRejection

Interview Angle

How to Explain it in an Interview

The 30-Second Spoken Pitch
"uncaughtException fires when a synchronous error isn't caught anywhere, and unhandledRejection fires when a promise rejects without a catch. Since Node 15, both crash the process by default. I treat them as a last safety net, not as error handling. After an unexpected error, the app's state can't be trusted, so in the handler I log the error, stop the server from taking new requests, let in-flight requests finish, close database connections, and exit with code 1. PM2, Docker, or Kubernetes then starts a fresh process. Most errors should never get that far: I use try-catch around awaits, a central Express error middleware, and in Express 4 an async wrapper so rejected promises reach that middleware."

Common mistakes:

  • Logging the error and keeping the process running. The app may be in a broken state.
  • Calling process.exit(1) immediately, which cuts off in-flight requests and may lose logs that haven't been written yet.
  • Calling an async function without await or .catch(), which is the most common source of unhandled rejections.
  • Thinking try/catch around setTimeout or an event listener catches errors inside the callback.

Summary

1uncaughtException → sync error nobody caught
2unhandledRejection → rejected promise nobody handled (crashes by default since Node 15)
3Handler's job → log → stop new requests → finish open ones → close DB → exit(1)
4Restart → PM2 / Docker / Kubernetes starts a fresh process
5Don't → keep running after a fatal error
6Express → central error middleware; async wrapper in Express 4
7Prevent → await + try/catch, .catch(), stream 'error' listeners

The same shutdown steps run on a normal deploy too. Next, graceful shutdown covers how to stop a Node.js server without dropping a single request.

Next TopicGraceful Shutdown on SIGTERM