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:
| Event | Fires when | Example |
|---|---|---|
uncaughtException | A synchronous error is thrown and nothing catches it | order.id when order is null, inside a setTimeout callback |
unhandledRejection | A 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 thrown2 → caught by try/catch / .catch()? → handled normally ✓3 → Express route? → error middleware → 500 response ✓4 → nothing caught it5 → uncaughtException / unhandledRejection6 → log it → shut down cleanly → exit(1)7 → PM2 / Docker / Kubernetes restarts the appHow Each One Happens
// 1. uncaughtException: sync error with no try/catch around itsetTimeout(function () {const order = null;console.log(order.id); // TypeError: Cannot read properties of null}, 1000);// 2. unhandledRejection: promise fails, nobody handles itasync 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 uncaughtExceptionconst 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:
- Log the error with full details.
- Stop accepting new requests.
- Finish requests already in progress and close database connections.
- 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 shutdownshuttingDown = true;if (err) logger.error({ err: err }, 'Fatal: ' + reason);else logger.info('Shutting down: ' + reason);// force exit if cleanup hangssetTimeout(function () { process.exit(exitCode); }, 10000).unref();server.close(async function () { // stop new requests, finish open onestry {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 / PM2process.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 codeclass 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 lastapp.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 errors | Programmer errors (bugs) | |
|---|---|---|
| What | Expected problems at runtime | Mistakes in the code |
| Examples | Order not found, invalid coupon, payment gateway timeout, DB connection dropped | Reading a property of undefined, wrong argument type, forgotten await |
| Handle by | Catch, respond with 4xx/5xx, retry if useful | Fix the code; at runtime, log and restart |
| Usually ends in | Express error middleware | uncaughtException / unhandledRejection |
Interview Angle
How to Explain it in an Interview
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
awaitor.catch(), which is the most common source of unhandled rejections. - Thinking
try/catcharoundsetTimeoutor an event listener catches errors inside the callback.
Summary
1uncaughtException → sync error nobody caught2unhandledRejection → 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 process5Don't → keep running after a fatal error6Express → central error middleware; async wrapper in Express 47Prevent → await + try/catch, .catch(), stream 'error' listenersThe 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.