Node.js is beloved for its non‑blocking, event‑driven architecture—but that same model can let critical errors slip through the cracks. Two of the most common culprits are:
- Uncaught Exceptions: errors thrown but never caught
-
Unhandled Promise Rejections: promise rejections without a
.catch()
Left unchecked, either will terminate your process without giving you a chance to clean up or alert your team. In this post, we’ll explore what these events are, why they matter, and how to handle them properly in production.
Understanding the Error Events
process.on('uncaughtException')
process.on('uncaughtException', (err) => {
console.error('💥 Uncaught Exception:', err);
// Last‑ditch cleanup or logging…
shutdownAndExit();
});
-
When it fires: A synchronous exception bubbles all the way up without a surrounding
try/catch
. - Default behavior: Node logs the error and crashes the process.
process.on('unhandledRejection')
process.on('unhandledRejection', (reason, promise) => {
console.error('🚨 Unhandled Rejection:', reason);
// You might even re‑throw to convert it into an uncaughtException
throw reason;
});
-
When it fires: A
Promise
rejects and there’s no.catch()
. - Since Node.js v15+: These are treated like uncaught exceptions and will crash the process by default.
Why You Can’t Ignore Them
- Data Integrity: Without cleanup, open database connections or in‑flight writes can be lost.
- Resource Leaks: Timers, sockets, and file handles may never close.
- Silent Failures: Your monitoring may not detect the root cause if the process simply disappears.
- Security Risks: An attacker‑triggered exception could leave your app in an inconsistent or vulnerable state.
Best Practices for Handling Fatal Errors
✅ Do | ❌ Don’t |
---|---|
Log full stack traces and context (request IDs, payloads, etc.) | Continue running as if nothing happened |
Report to external error‑tracking services (Sentry, Datadog, etc.) | Swallow errors or leave handlers empty |
Gracefully shut down: close DB, stop accepting new requests | Call process.exit(0) (signals success) |
Use a supervisor (e.g., PM2, Docker restart policy) | Believe you can recover 100% safely in‑process |
Convert unhandled rejections into exceptions (throw reason ) |
Ignore Node.js v15+ default behavior—explicit is better |
Putting It All Together: Graceful Shutdown
// app.js
const http = require('http');
const server = http.createServer((req, res) => {
// your handler logic…
res.end('Hello World');
});
server.listen(3000, () => console.log('Server listening on 3000'));
function shutdownAndExit() {
console.log('🔒 Closing server…');
server.close(() => {
console.log('✅ Server closed. Exiting.');
process.exit(1);
});
// Force‑exit after 5s
setTimeout(() => process.exit(1), 5000);
}
process.on('uncaughtException', (err) => {
console.error('💥 Uncaught Exception:', err);
shutdownAndExit();
});
process.on('unhandledRejection', (reason) => {
console.error('🚨 Unhandled Rejection:', reason);
// Optionally convert into uncaughtException for unified handling
throw reason;
});
- Log the error immediately for diagnostics.
- Close the HTTP server so no new connections are accepted.
- Exit with a non‑zero code to signal failure to your orchestrator.
Beyond the Basics
- Domain-based separation: (deprecated) can isolate error scopes, but has pitfalls.
- Worker threads: use message passing and parent thread supervision.
- Crash-only design: embrace frequent restarts; keep startup fast and idempotent.
Conclusion
Unhandled exceptions and rejections are among the stealthiest threats to your Node.js application’s stability.
By proactively catching them, logging with context, and performing a graceful shutdown, you turn silent killers into manageable events—keeping your services resilient and your team informed.
If you found this helpful, feel free to share
Let’s connect!!: 🤝