Stopping the Crash: Solving Node.js [ERR_UNHANDLED_ERROR]

intermediate💚 Node.js2026-07-27| Node.js (All versions), Linux/macOS/Windows, CommonJS and ES Modules.

Error Message

Error [ERR_UNHANDLED_ERROR]: Unhandled error.
#nodejs#eventemitter#backend#javascript#debugging

The Context

Last week, a background worker in our production environment died without warning. The logs didn't provide much context, just one frustrating line: Error [ERR_UNHANDLED_ERROR]: Unhandled error.

In Node.js, the 'error' event is a special case. When an object inherits from EventEmitter—think net sockets, streams, or custom classes—it expects someone to be listening. If you emit an error and no listener is registered, Node.js triggers its default behavior. It throws an exception, prints a stack trace, and terminates the process with an exit code 1.

This fail-fast mechanism prevents errors from being silently ignored. However, it can be a nightmare when a single unhandled edge case brings down your entire API or worker service.

The Debug Process

Reproducing this is simple. You just need to trigger an error on an emitter that lacks an .on('error') handler. Here is a 5-line script that will crash your terminal immediately:

const EventEmitter = require('events');
const myEmitter = new EventEmitter();

// This triggers the crash because no listener exists
myEmitter.emit('error', new Error('Database connection failed!'));

When I hunt for this in a large codebase, I focus on three specific areas:

  • Custom Emitters: Any class extending EventEmitter that emits errors during asynchronous tasks.
  • File Streams: Pipe chains where a source fails, such as fs.createReadStream attempting to open a missing .env file.
  • Socket Connections: Network requests that time out or encounter a reset but lack a catch-all error handler.

If the stack trace is too shallow to be useful, try running your app with the --trace-uncaught flag. It provides a more detailed look at where the emitter was first instantiated.

Solutions to Fix ERR_UNHANDLED_ERROR

1. Add an Explicit 'error' Listener

Your first line of defense is ensuring every emitter has a listener. Even a simple logger prevents the process from exiting. It keeps the application alive while you investigate the root cause.

const EventEmitter = require('events');
const myEmitter = new EventEmitter();

// Handle the error event to prevent the crash
myEmitter.on('error', (err) => {
  console.error('Caught the error properly:', err.message);
});

myEmitter.emit('error', new Error('Something went wrong'));
console.log('The process is still running!');

2. Handling Errors in Streams

Streams are notorious for this error. Many developers assume .pipe() forwards errors down the chain, but it doesn't. If the source fails, the whole process dies. Since Node.js 10.0.0, the best solution is stream.pipeline.

const fs = require('fs');
const { pipeline } = require('stream');

// pipeline handles cleanup and consolidates error handling
pipeline(
  fs.createReadStream('missing-file.txt'),
  fs.createWriteStream('output.txt'),
  (err) => {
    if (err) {
      console.error('Pipeline failed gracefully:', err.message);
    }
  }
);

3. Using the 'captureRejections' Option

Node.js 12.6.0 introduced a way to bridge the gap between Promises and events. By setting captureRejections: true, you can use async functions as listeners without worrying about unhandled rejections crashing the emitter.

const EventEmitter = require('events');
const myEmitter = new EventEmitter({ captureRejections: true });

myEmitter.on('event', async (value) => {
  throw new Error('Async failure');
});

// The rejection is automatically converted into an 'error' event
myEmitter.on('error', (err) => {
  console.log('Captured async error:', err.message);
});

myEmitter.emit('event');

4. Using events.once with try-catch

If you prefer async/await syntax over callbacks, use events.once. This utility wraps an event in a Promise. Just remember that if the emitter emits 'error' while you are waiting, the Promise will reject.

const { once, EventEmitter } = require('events');

async function run() {
  const ee = new EventEmitter();
  
  try {
    const promise = once(ee, 'finish');
    ee.emit('error', new Error('Instant fail'));
    await promise;
  } catch (err) {
    console.error('Caught via try-catch:', err.message);
  }
}

run();

Verification Steps

To confirm the fix, I use a three-step validation process:

  • Simulate the failure: Manually trigger the error by providing a bad file path or a 127.0.0.1 address that isn't listening.
  • Check the Exit Code: Run your script in a terminal and immediately run echo $?. A 0 means success; a 1 means it still crashed.
  • Inspect Logs: Verify that your logger (like Pino or Winston) captured the full stack trace instead of just a generic error message.

Lessons Learned

  • Listener discipline: If you build a class using EventEmitter, document that users must handle the 'error' event.
  • Prefer pipeline: Use stream.pipeline instead of .pipe() for almost all production stream operations. It is safer and cleaner.
  • Centralized Logging: Always log the full error object. Losing the stack trace in production makes debugging 10x harder.
  • Avoid global handlers: process.on('uncaughtException') is a band-aid. It can leave your app in a corrupted state. Fix the specific emitter instead.

Related Error Notes