Fix: Node.js UnhandledPromiseRejection and uncaughtException — Crashing Server
Part of: JavaScript & TypeScript Errors
Quick Answer
How to handle Node.js uncaughtException and unhandledRejection events — graceful shutdown, error logging, async error boundaries, and keeping servers alive safely.
The Error
Node.js crashes with an unhandled exception:
/app/server.js:45
throw new Error('Something went wrong');
^
Error: Something went wrong
at processTicksAndMicrotasks (internal/process/task_queues.js:95:5)
node:internal/process/promises:288
triggerUncaughtException(err, true /* fromPromise */);
^
[DEP0018] DeprecationWarning: Unhandled promise rejections are deprecated.
In future versions of Node.js, promise rejections that are not handled will terminate
the Node.js process with a non-zero exit code.Or in Node.js 15+, the process exits immediately:
node:internal/process/promises:246
triggerUncaughtException(err, true /* fromPromise */);
^
Error [ERR_UNHANDLED_REJECTION]: Database connection failed
at Timeout._onTimeout (/app/db.js:23:11)Or an intermittent crash with no error message — the process just exits.
Why This Happens
Node.js has two process-level events for unhandled errors:
uncaughtException— a synchronous throw that wasn’t caught by anytry/catch. In older code, this was common with callbacks.unhandledRejection— a Promise rejection that had no.catch()handler and wasn’t awaited inside atry/catch. This became the dominant source of crashes when async/await was adopted.
The Node.js process exits on unhandledRejection since Node.js 15. In Node.js 14 and earlier, it printed a deprecation warning but continued running — this masked bugs that would have crashed newer versions.
The critical point that many developers miss: the process.on('uncaughtException') handler is not a place to recover and continue. The Node.js documentation explicitly states that after an uncaught exception, the process is in an undefined state. Internal data structures may be corrupt, pending I/O may be in an inconsistent state, and continuing to serve requests risks data corruption or silent failures. The handler exists for one purpose only — to log the error and perform cleanup (close database connections, flush logs) before exiting. Swallowing the exception and continuing is the single most dangerous pattern in Node.js error handling.
Common causes of unhandled errors:
- Async function called without
await— the returned Promise is discarded; any rejection becomes unhandled. - Promise chain missing
.catch()— a.then()chain with no terminal.catch(). setTimeoutorsetIntervalcallback throwing — errors in timer callbacks are uncaught exceptions.- Event emitter throwing — errors emitted as
'error'events with no'error'listener. - Database connection failures — async connection setup that fails after the app starts.
Diagnostic Timeline
When a Node.js process crashes intermittently, finding the root cause requires methodical investigation rather than immediately adding global handlers.
Minute 0 — Read the stack trace. The first line after “Error:” tells you what failed. The stack trace tells you where. If the trace includes processTicksAndMicrotasks or runMicrotasks, the error originated in a Promise (unhandled rejection). If it shows a direct function call chain from your code, it’s a synchronous throw.
Minute 2 — Classify the error type. Check whether the crash says uncaughtException or unhandledRejection. This distinction matters because the fix is different. For unhandled rejections, you need to find the missing await or .catch(). For uncaught exceptions, you need to find the missing try/catch around synchronous code, or an event emitter without an error listener.
Minute 5 — Run with --trace-warnings and --trace-uncaught. These flags give you the full creation-time stack trace, not just where the error was thrown:
node --trace-warnings --trace-uncaught server.jsThe output shows where the Promise was created (not just where it rejected). This is invaluable when the rejection happens in a callback chain far from where the Promise originated.
Minute 8 — Check if domain or cluster is involved. Legacy codebases sometimes use Node.js domain (deprecated) to group async operations. Domains can intercept errors in unexpected ways — an error you think should be caught by a try/catch might instead be caught by a domain handler, or vice versa. If your code uses require('domain') or require('cluster'), the error routing is more complex than standard behavior.
Minute 10 — Search for fire-and-forget async calls. These are the most common cause of unhandled rejections in production:
grep -rn "async\|\.then\|Promise" --include="*.js" --include="*.ts" src/ | grep -v "await\|\.catch\|try"Look for async functions called without await, .then() chains without .catch(), and Promise.all() without error handling.
Minute 15 — Add the global handler for cleanup, not recovery. Once you find and fix the root cause, add a global unhandledRejection handler as a safety net that logs and gracefully shuts down. Do not use it as the primary error handling strategy.
Fix 1: Handle unhandledRejection at the Process Level
Add a global handler to catch any promise rejection that escapes normal error handling:
// At the top of your entry point (index.js / server.js)
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled Promise Rejection:');
console.error('Reason:', reason);
console.error('Promise:', promise);
// Log to error monitoring (Sentry, Datadog, etc.)
errorMonitoring.captureException(reason);
// Graceful shutdown — the process is in an uncertain state
// Don't catch and continue; exit cleanly
server.close(() => {
process.exit(1);
});
});
process.on('uncaughtException', (error) => {
console.error('Uncaught Exception:', error);
errorMonitoring.captureException(error);
// Always exit after uncaughtException
// The process state is corrupted — don't try to continue
server.close(() => {
process.exit(1);
});
});Warning: Do NOT use
unhandledRejectionas a catch-all to swallow errors and keep running. The handler should log the error, notify monitoring, and initiate a graceful shutdown. An unhandled rejection means a bug exists — fix the root cause, don’t suppress it.
Fix 2: Always await Async Functions
The most common source of unhandled rejections — calling an async function without await:
// WRONG — rejection is unhandled (promise is discarded)
async function saveUser(data) {
await db.insert('users', data);
}
// Called without await — the returned Promise is ignored
router.post('/users', (req, res) => {
saveUser(req.body); // Promise returned and discarded — if it rejects, crash
res.json({ status: 'ok' });
});
// CORRECT — await the async function
router.post('/users', async (req, res, next) => {
try {
await saveUser(req.body); // Awaited — rejections are caught
res.json({ status: 'ok' });
} catch (err) {
next(err); // Pass to Express error handler
}
});Promise.all without handling rejections:
// WRONG — if any promise rejects, the rejection is unhandled
Promise.all([
fetchUser(id),
fetchPermissions(id),
fetchSettings(id),
]);
// CORRECT — handle the rejection
Promise.all([
fetchUser(id),
fetchPermissions(id),
fetchSettings(id),
])
.then(([user, permissions, settings]) => {
// process results
})
.catch(err => {
console.error('Failed to load user data:', err);
// Handle the error
});
// Or with async/await
async function loadUserData(id) {
try {
const [user, permissions, settings] = await Promise.all([
fetchUser(id),
fetchPermissions(id),
fetchSettings(id),
]);
return { user, permissions, settings };
} catch (err) {
throw new AppError('Failed to load user data', { cause: err });
}
}Fix 3: Handle Event Emitter Errors
Node.js’s EventEmitter has special handling for the 'error' event. If an 'error' event fires with no listener, Node.js throws an uncaught exception:
const { EventEmitter } = require('events');
const emitter = new EventEmitter();
// WRONG — no 'error' listener
emitter.emit('error', new Error('something failed'));
// Uncaught 'Error' [ERR_UNHANDLED_ERROR]: Unhandled error. (something failed)
// Process crashes
// CORRECT — always add an 'error' listener to EventEmitters you use
emitter.on('error', (err) => {
console.error('EventEmitter error:', err);
});Streams (which extend EventEmitter):
const fs = require('fs');
// WRONG — no error handler on readable stream
const stream = fs.createReadStream('/nonexistent/file.txt');
stream.on('data', chunk => console.log(chunk));
// If file doesn't exist -> uncaught error -> crash
// CORRECT — always handle stream errors
const stream = fs.createReadStream('/path/to/file.txt');
stream.on('data', chunk => process.stdout.write(chunk));
stream.on('error', err => {
console.error('Stream error:', err.message);
// Handle appropriately
});
stream.on('end', () => console.log('Done'));HTTP server and socket errors:
const http = require('http');
const server = http.createServer(app);
// Handle server-level errors (port already in use, etc.)
server.on('error', (err) => {
if (err.code === 'EADDRINUSE') {
console.error(`Port ${PORT} is already in use`);
process.exit(1);
}
throw err;
});
// Handle errors on individual socket connections
server.on('clientError', (err, socket) => {
socket.end('HTTP/1.1 400 Bad Request\r\n\r\n');
});Fix 4: Implement Graceful Shutdown
A process restart (after a crash) is unavoidable — implement graceful shutdown to minimize disruption:
const express = require('express');
const app = express();
const server = app.listen(3000);
let isShuttingDown = false;
function gracefulShutdown(signal) {
console.log(`Received ${signal} — starting graceful shutdown`);
isShuttingDown = true;
// Stop accepting new connections
server.close(async () => {
console.log('HTTP server closed');
try {
// Close database connections
await db.pool.end();
console.log('Database connections closed');
// Close other resources (Redis, message queues, etc.)
await redis.quit();
console.log('Redis connection closed');
console.log('Graceful shutdown complete');
process.exit(0);
} catch (err) {
console.error('Error during shutdown:', err);
process.exit(1);
}
});
// Force exit if graceful shutdown takes too long
setTimeout(() => {
console.error('Graceful shutdown timed out — forcing exit');
process.exit(1);
}, 30000); // 30 second timeout
}
// Reject new requests during shutdown
app.use((req, res, next) => {
if (isShuttingDown) {
res.setHeader('Connection', 'close');
return res.status(503).json({ error: 'Server is shutting down' });
}
next();
});
// Handle shutdown signals
process.on('SIGTERM', () => gracefulShutdown('SIGTERM')); // Kubernetes, Docker stop
process.on('SIGINT', () => gracefulShutdown('SIGINT')); // Ctrl+C
// Handle unhandled errors
process.on('uncaughtException', (err) => {
console.error('Uncaught Exception:', err);
gracefulShutdown('uncaughtException');
});
process.on('unhandledRejection', (reason) => {
console.error('Unhandled Rejection:', reason);
gracefulShutdown('unhandledRejection');
});Fix 5: Use a Process Manager to Auto-Restart
Since crashes can happen even with good error handling, run Node.js under a process manager that automatically restarts the process:
PM2:
npm install -g pm2
# Start with auto-restart
pm2 start server.js --name "api-server"
pm2 start server.js --name "api-server" --max-restarts 10 --restart-delay 5000
# Ecosystem file for production// ecosystem.config.js
module.exports = {
apps: [{
name: 'api-server',
script: 'server.js',
instances: 'max', // Use all CPU cores
exec_mode: 'cluster',
max_restarts: 10, // Max restarts in restart_delay period
restart_delay: 5000, // Wait 5s before restart
max_memory_restart: '500M', // Restart if memory exceeds 500MB
env: {
NODE_ENV: 'production',
PORT: 3000,
},
error_file: '/var/log/pm2/api-error.log',
out_file: '/var/log/pm2/api-out.log',
}],
};Docker with restart policy:
# docker-compose.yml
services:
api:
image: my-api:latest
restart: unless-stopped # Always restart except on explicit stop
# Or for production:
# restart: always
# Kubernetes handles restarts automatically via Pod restart policyFix 6: Use Domain for Legacy Code (Deprecated but Informational)
Node.js had domain for grouping async operations and handling their errors — it was deprecated and should not be used in new code. The modern equivalent is proper async/await error handling:
// LEGACY — don't use in new code
const domain = require('domain');
const d = domain.create();
d.on('error', (err) => {
console.error('Domain caught:', err);
});
d.run(() => {
// Async operations inside the domain have errors caught by d.on('error')
setTimeout(() => {
throw new Error('Caught by domain');
}, 100);
});
// MODERN equivalent — proper try/catch with async/await
async function runOperation() {
try {
await asyncOperation();
} catch (err) {
console.error('Caught:', err);
}
}Fix 7: Structured Error Logging
Make unhandled errors visible and actionable with structured logging:
const { createLogger, format, transports } = require('winston');
const logger = createLogger({
format: format.combine(
format.timestamp(),
format.errors({ stack: true }),
format.json(),
),
transports: [
new transports.Console(),
new transports.File({ filename: 'error.log', level: 'error' }),
],
});
process.on('unhandledRejection', (reason, promise) => {
logger.error('Unhandled Promise Rejection', {
reason: reason instanceof Error ? {
message: reason.message,
stack: reason.stack,
name: reason.name,
} : reason,
timestamp: new Date().toISOString(),
});
// Initiate graceful shutdown
gracefulShutdown('unhandledRejection');
});
process.on('uncaughtException', (error) => {
logger.error('Uncaught Exception', {
message: error.message,
stack: error.stack,
name: error.name,
});
gracefulShutdown('uncaughtException');
});Error monitoring with Sentry:
const Sentry = require('@sentry/node');
Sentry.init({
dsn: process.env.SENTRY_DSN,
environment: process.env.NODE_ENV,
});
// Sentry automatically captures unhandledRejection and uncaughtException
// when initialized — no manual .on() needed for basic capture
// For custom context:
process.on('unhandledRejection', (reason) => {
Sentry.captureException(reason);
gracefulShutdown('unhandledRejection');
});Still Not Working?
Find the source of the rejection — add a --trace-warnings flag to see where the unhandled rejection originated:
node --trace-warnings --trace-uncaught server.js
# Shows a full stack trace including where the Promise was createdAsync event handlers — event handlers that are async but not awaited cause silent unhandled rejections:
// WRONG — EventEmitter doesn't await async listeners
emitter.on('data', async (event) => {
await processEvent(event); // If this throws, rejection is unhandled
});
// CORRECT — catch errors inside the async listener
emitter.on('data', async (event) => {
try {
await processEvent(event);
} catch (err) {
emitter.emit('error', err); // Re-emit as error event
}
});Promise.allSettled instead of Promise.all for non-critical operations:
// Promise.all rejects on first failure — other promises continue untracked
// Promise.allSettled waits for all, never rejects, reports each result
const results = await Promise.allSettled([
sendWelcomeEmail(user),
updateAnalytics(user),
notifyAdmin(user),
]);
for (const result of results) {
if (result.status === 'rejected') {
console.error('Non-critical operation failed:', result.reason);
}
}Express 4 does not catch async errors by default — if an async route handler throws, Express does not catch it. Either wrap every handler in a try/catch with next(err), or use express-async-errors which patches Express to handle async rejections:
npm install express-async-errorsrequire('express-async-errors'); // Must be required before routes
const express = require('express');
const app = express();
// Now async handlers automatically forward errors to Express error middleware
app.get('/users', async (req, res) => {
const users = await db.getUsers(); // If this throws, Express catches it
res.json(users);
});process.exit() inside the unhandledRejection handler doesn’t flush logs — process.exit() terminates immediately, before pending I/O (including log writes) finishes. Use server.close() with a callback, and call process.exit() only after cleanup completes. Or use process.exitCode = 1 and let the process exit naturally after the event loop drains.
Crash without any error output — if the process exits with no error message, it might be an out-of-memory kill (OOMKilled by the OS or container runtime). Check dmesg on Linux or the container’s exit code (137 = SIGKILL from OOM). This is not an uncaughtException — Node.js never gets a chance to handle it.
For related Node.js issues, see Fix: Express Async Error Not Being Caught, Fix: Node.js Heap Out of Memory, Fix: JavaScript Unhandled Promise Rejection, and Fix: Node.js Stream Error.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Fastify Not Working — 404, Plugin Encapsulation, and Schema Validation Errors
How to fix Fastify issues — route 404 from plugin encapsulation, reply already sent, FST_ERR_VALIDATION, request.body undefined, @fastify/cors, hooks not running, and TypeScript type inference.
Fix: Express Async Error Not Being Caught — Unhandled Promise Rejection
How to fix Express async route handlers not passing errors to the error middleware — wrapping async routes, using express-async-errors, global error handlers, and Node.js unhandledRejection events.
Fix: pdf-lib Not Working — PDF Not Generating, Fonts Not Embedding, or Pages Blank
How to fix pdf-lib issues — creating PDFs from scratch, modifying existing PDFs, embedding fonts and images, form filling, merging documents, and browser and Node.js usage.
Fix: Pusher Not Working — Events Not Received, Channel Auth Failing, or Connection Dropping
How to fix Pusher real-time issues — client and server setup, channel types, presence channels, authentication endpoints, event binding, connection management, and React integration.