Handling the 'error' Event
Among all the events an EventEmitter can carry, one name is special: error. Node.js treats 'error' as a last-resort signal of failure, and it enforces a hard rule — if an emitter fires error and no listener is attached, the error is thrown rather than swallowed, typically taking the whole process down. This behavior is deliberate: a silently dropped error is far more dangerous than a loud crash. Understanding the rule, and the handful of APIs around it, is essential for writing emitters and streams that fail safely.
Why an unhandled error crashes the process
When you call emit('error', err) on an EventEmitter that has no error listener, the emitter throws the error value. Because emit runs synchronously and there is usually no surrounding try/catch at the point a stream or socket emits, the throw propagates to the top of the stack and becomes an uncaught exception — which by default terminates the Node process.
import { EventEmitter } from 'node:events';
const emitter = new EventEmitter();
// No 'error' listener is registered.
emitter.emit('error', new Error('something went wrong'));
console.log('This line never runs');
Output:
node:events:496
throw er; // Unhandled 'error' event
^
Error: something went wrong
at file:///app/index.js:6:18
...
Emitted 'error' event on EventEmitter instance at:
...
The process exits with a non-zero code and 'This line never runs' is never printed. This is not a bug — it is the documented contract. Any emitter that can fail (HTTP servers, streams, child processes, sockets) can emit error, so each one needs a handler.
Catching the throw with a surrounding
try/catchonly works whenemit('error')is called synchronously in your own code. For built-in emitters that emit asynchronously — a stream failing mid-read, a socket timing out — there is no synchronous call to wrap. You must attach anerrorlistener instead.
Handling errors correctly
The fix is simple: register a listener for the 'error' event. Once at least one listener exists, the emitter no longer throws — it just invokes your handler with the error value, exactly like any other event.
import { EventEmitter } from 'node:events';
const emitter = new EventEmitter();
emitter.on('error', (err) => {
console.error('Handled error:', err.message);
});
emitter.emit('error', new Error('connection refused'));
console.log('Execution continues normally');
Output:
Handled error: connection refused
Execution continues normally
For long-lived emitters such as servers and streams, attach the error listener before any operation that could fail, so a failure during setup is never missed.
import { createReadStream } from 'node:fs';
const stream = createReadStream('/path/that/does/not/exist.txt');
stream.on('error', (err) => {
console.error(`Stream failed: ${err.code}`);
});
stream.on('data', (chunk) => console.log(`Read ${chunk.length} bytes`));
Output:
Stream failed: ENOENT
Without that error listener, the missing file would emit error, find no handler, and crash the program.
Monitoring errors with errorMonitor
Sometimes you want to observe errors — for logging, metrics, or tracing — without changing the special throw-on-unhandled behavior. A normal .on('error', fn) listener disables the crash, which can mask configuration mistakes elsewhere. The events.errorMonitor symbol solves this: a listener installed under this symbol is called whenever an error is emitted, but it does not count as a regular handler. If no real error listener exists, the error still throws after your monitor runs.
import { EventEmitter, errorMonitor } from 'node:events';
const emitter = new EventEmitter();
emitter.on(errorMonitor, (err) => {
console.log('Monitor saw:', err.message); // observe, do not handle
});
emitter.on('error', (err) => {
console.log('Real handler:', err.message); // this one prevents the crash
});
emitter.emit('error', new Error('disk full'));
Output:
Monitor saw: disk full
Real handler: disk full
If you remove the real 'error' handler above, the monitor still logs disk full, and then the process crashes — which is exactly the point. The monitor lets observability code see every error without accidentally suppressing the safety net.
| Approach | Receives error | Prevents crash | Use for |
|---|---|---|---|
.on('error', fn) | Yes | Yes | Actual error handling and recovery |
.on(errorMonitor, fn) | Yes | No | Logging, metrics, tracing |
| No listener | — | No (throws) | Never acceptable on failable emitters |
The process-level safety net
For truly unexpected cases, process.on('uncaughtException', ...) catches errors that escaped every emitter. Treat this as a place to log and shut down gracefully, not as routine error handling — the process is in an undefined state afterward and should exit.
process.on('uncaughtException', (err) => {
console.error('Fatal, shutting down:', err.message);
process.exit(1);
});
Best Practices
- Attach an
errorlistener to every emitter that can fail — servers, streams, sockets, and child processes — before you start using it. - Register the listener before triggering the operation, so failures during setup are not missed.
- Never leave a failable emitter without an
errorhandler; an unhandlederrorevent throws and crashes the process. - Use
events.errorMonitorfor logging and metrics so observability code never accidentally suppresses the crash-on-unhandled safety behavior. - Keep a single real
errorhandler responsible for recovery; use the monitor symbol for everything that only needs to observe. - Reserve
process.on('uncaughtException')as a last-resort logger that exits the process, not as a substitute for per-emitter handlers.