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
EventEmitterthat emits errors during asynchronous tasks. - File Streams: Pipe chains where a source fails, such as
fs.createReadStreamattempting to open a missing.envfile. - 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 $?. A0means success; a1means 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.pipelineinstead 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.

