Back to writing
Glib Rulev
Glib Rulev

Mastering Error Handling in Node.js. UnhandledRejection, uncaughtException, and Beyond

In Node.js, errors don’t just crash your app - they can expose security risks, cause memory leaks, or leave services in an undefined state. That ‘s why mastering error handling isn’t just a good practice - it’s essential.

In this guide, we’ll break down the three core error events in Node.js, how and when to use them, and share production-ready patterns you can apply immediately.


🔑 Key Takeaways

  • unhandledRejection: For Promise errors that lack a .catch()
  • uncaughtException: For sync errors that escape try/catch
  • uncaughtExceptionMonitor: For observing fatal errors before they crash
  • Use graceful shutdowns and always log with context

⚙️ The Node.js Error Event Trilogy

1. unhandledRejection: Catch the Async Gaps

Triggered when a Promise rejects and there’s no .catch() to handle it.

process.on('unhandledRejection', (reason, promise) => {
  logger.fatal('Unhandled rejection', { reason });
  metrics.increment('errors.unhandledRejection');
   
  if (reason instanceof CriticalError) {
    // Perform graceful shutdown
    shutdownAndExit(1);
  }
});

When to Use:

  • Tracking potentially leaked async operations
  • Identifying missing error handlers in promise chains

💡 Tip: In production, track these with timestamps or correlation IDs for debugging async chains.


2. uncaughtException: Your Last Resort

Used to catch sync errors that aren’t handled anywhere else.

process.on('uncaughtException', (err) => {
  logger.error('Uncaught Exception', { stack: err.stack });
  process.exit(1); // Always exit after
});

Critical Guidelines:

  • Never continue normal execution
  • Always exit with non-zero code

After catching an uncaughtException, never continue normal app logic - the application’s internal state may be unreliable.


3. uncaughtExceptionMonitor: Observe Without Interfering

This runs before uncaughtException and is ideal for observability tools like Sentry.

process.on('uncaughtExceptionMonitor', (err) => {
  sentry.captureException(err, {
    tags: { source: 'uncaughtException' }
  });
});

📊 Great for pre-crash diagnostics or forensic logging.


🏗 Framework Integrations

Fastify example:

// plugins/errorHandler.js
export default async (fastify) => {
  process.on('unhandledRejection', (err) => {
    fastify.log.fatal(err);
    process.exit(1);
  });

  fastify.setErrorHandler((error, request, reply) => {
    request.log.error(error);
    reply.status(500).send({ error: 'Internal Server Error' });
  });
};

Benefits:

  • Combines process-level and route-level handling
  • Recommended shutdown workflow:
    1. Close incoming connections
    2. Finish ongoing requests
    3. Exit process

Pair this with Fastify’s lifecycle hooks to ensure a clean shutdown and log everything reliably.


NestJS example:

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  const logger = app.get(Logger);

  process.on('unhandledRejection', (reason) => {
    logger.error('Unhandled Rejection', reason);
  });

  process.on('uncaughtException', async (err) => {
    logger.fatal('Uncaught Exception', err.stack);
    await app.close();
    process.exitCode = 1; // Allows any final tasks to finish
  });

  await app.listen(3000);
}

Use app.close() to ensure all resources (like DB or messaging) shut down properly.


🔍 Advanced Use Cases

1. Memory Leak Detection

const rejections = new Map();

process.on('unhandledRejection', (reason, promise) => {
  rejections.set(promise, Date.now());
});

process.on('rejectionHandled', (promise) => {
  const delay = Date.now() - rejections.get(promise);
  if (delay > 5000) alertMemoryLeak();
});

2. Distributed Tracing

process.on('unhandledRejection', (reason) => {
  const traceId = getCurrentTraceId();
  logger.error(`[${traceId}] Unhandled Rejection`, reason);
});

✅ Best Practices Checklist

  • ✅ Always log with context (stack trace, request ID, user)
  • ✅ Use process.exitCode to allow async cleanup
  • ✅ Combine process-level handlers with local try/catch
  • ✅ Add synthetic tests to verify crash handlers
  • ❌ Never ignore unhandledRejection warnings
  • ❌ Don’t resume normal app logic after uncaughtException

🚀 Final Thoughts

Error handling in Node.js is not just about catching bugs - it’s about building systems that can fail gracefully. By combining low-level process hooks with structured logging, observability tools, and graceful shutdown flows, you can dramatically improve your app’s reliability.

Want your app to stay online at 3AM? Handle your errors like a pro.

Happy coding. 🛠️