Promises and Node.js event emitters don't mix
Published
To many experienced Node developers, the title of this post will seem intuitively obvious. Nevertheless, it's useful to see what unexpected behaviour can occur when the two are used together. Here's an example:
const { EventEmitter } = require('events');
const emitter = new EventEmitter();
Promise.reject(new Error('Oh noes!'))
.catch(e => emitter.emit('error', e));
If you run this code (tested in Node v7.3 from a script, not REPL), what do you
expect to happen? I expected an uncaughtException
event, since an event
emitter is emitting an error event with no event handler, but that's not
what happens at all. Instead you get an UnhandledPromiseRejectionWarning
!
This is bad news. If something genuinely nasty has happened, you might want to
emit an error like this which is either explicitly handled or causes an
uncaughtException
event. Uncaught exceptions should lead to the process
exiting, and a promise has just stifled it.
So, what happened? Here's a hint:
const { EventEmitter } = require('events');
const emitter = new EventEmitter();
try {
emitter.emit('error', new Error('Oh noes!'));
} catch (e) {
console.log('caught');
}
What do you expect to happen here? Again, at first glance I would have expected
an uncaughtException
event. uncaughtException
events can happen when one of
two conditions is met. The first is an error is thrown but not caught (thus the
name). The second is when an error event is emitted and the emitter has no
handler to deal with it.
Did I say there were two conditions? I meant to say one! The second condition is really the first in disguise. When an emitter lacks an error handler and it emits an error event, the default error handler takes control and throws the error. Since event handlers are called synchronously upon event emissions, this:
const { EventEmitter } = require('events');
const emitter = new EventEmitter();
emitter.emit('error', new Error('Oh noes!'));
is equivalent to
throw new Error('Oh noes!');
So, the very first snippet is equivalent to:
Promise.reject(new Error('Oh noes!'))
.catch(e => {
throw e;
});
The promise chain captures the thrown error and wraps it as a rejected promise,
which leads to an UnhandledPromiseRejectionWarning
since a second .catch
is
needed in the chain to handle it.
If you want to ensure that unhandled error events lead to uncaught exceptions
which aren't captured by a catch
or a promise chain, then you can wrap the
emission in a setTimeout
or a setImmediate
.