Node.js and the struggles of being an EventTarget
In recent years, Node.js has seen increasing adoption of standard browser application programming interfaces (APIs), such as the WHATWG-compliant URL implementation, the TextEncoder/TextDecoder API or the Console API that now matches what browsers provide.
One API proving to be particularly fundamental is EventTarget
, the DOM interface for firing events, and it’s worth taking a closer look at how we’re bringing EventTarget
into Node.js. Doing so gives us insight into not only the process of bringing features into Node.js, but also how we deal with performance and compatibility problems, and what the future of Node.js might look like.
What is an EventTarget and why do we want it?
Loosely speaking, an EventTarget
is a JavaScript object that is associated with a list of event types , i.e. strings, on which event listeners can be registered for one of those event types and on which events can be dispatched. When an event of a given type is dispatched, the event listeners for that event type are called. A classic example on the web is the ‘click’ event: JavaScript code registers a handler for when a specific element on the web page is clicked, and the browser then dispatches those events.
And that’s where the trouble starts. In browsers, a click on a part of a web page is also a click on anything that contains that part, in which case the event “bubbles up” the DOM tree. Node.js does not have a concept of a DOM or similar tree-like structures. Typically, an event happens on a single object, such as when data is received on a socket, and only on that object.
That’s why Node.js has brought itself into the now-unfortunate situation of having EventEmitter
, an API that is almost, but not quite, entirely unlike EventTarget
. While EventEmitter
supports adding and removing listeners, the events are just plain JS values. There’s no concept of events bubbling up, preventing the default action that the browser would take and the subsequent actions.
Let’s take a look at the main differences:
It’s plain to see that while the two APIs do similar things, they use different object formats and naming conventions.
Bringing Web APIs into Node.js
As mentioned, Node.js has been integrating more and more web APIs. However, those APIs depend on each other and EventTarget
is often an integral building block. If we want to have specification-compliant fetch( )
in Node.js, we need a specification-compliant AbortController
implementation — and for that, we need a spec-compliant EventTarget
.
We actually already brought an API into Node.js that would have used EventTarget
if that had been available at that point: The MessageChannel/MessagePort API for cross-thread and cross-context communication uses EventTarget
in browsers. At the time, however, we decided to let it implement EventEmitter
instead and not deal with the EventTarget
problem, because we wanted to bring Worker threads into Node.js without the feature being blocked on this mismatch. Also, at that point, it would have been the only API to make use of EventTarget
. For those who do require some degree of web compatibility, we enabled the second, alternative way of registering EventTarget
listeners above (i.e. through the onmessage
property in this case).
Now, a few years later, there has been some progress: We have an experimental AbortController implementation coming up in Node.js 15! This also comes with the implementation of EventTarget
and enables us to revisit that earlier decision.
This also may enable us to eventually bring in a specification-compliant fetch( )
function. However, that remains a much larger point of discussion – for example, it would also require bringing in the Web streams API and figuring out how to make that play nicely with the Node.js built-in streams API.
Struggle 1: Compatibility
How do we deal with the fact that we already have one API for events in place and want to add a second one? We can’t just switch everything from one implementation to the other: If network sockets suddenly started being EventTargets instead of EventEmitters, every piece of code using them would have to be re-written. So that’s out of the question.
Luckily, there’s little overlap between the two APIs. This means we can create a FrankensteinEmitter
API that is pieced together from both. (In reality, the Node.js source currently picks the more boring but admittedly more accurate name NodeEventTarget
.) Ideally, we would enable the following kind of behaviour for MessagePort
, where all three kinds of registering listeners work:
Note that the Node.js-style listener receives the message data directly, whereas the other two methods receive an Event
object with a data property. The latter is dictated by web compatibility, the former by compatibility with existing Node.js versions. (We actually switched .onmessage to the Web-style format so that we could eventually bring in EventTarget
compatibility while Workers were still considered experimental.)
After the changes described here, the MessagePort
class no longer inherits from EventEmitter
: Instead, it becomes a subclass of EventTarget
through the magic of our internal NodeEventTarget
implementation. While all methods are still there, it’s an observable change, and we’re accepting a low risk of breakage in exchange for the extended Web compatibility.
Struggle 2: Multiple inheritance
JavaScript enforces a pattern in which any class can inherit only from a single other class. Ninety-nine per cent of the time, that’s perfectly reasonable, and it keeps the inheritance system (which is already hard to learn) simple. However, this poses a challenge.
On the one hand, MessagePort
objects must be implemented in C++. This is primarily because they are so-called transferables and can be shipped to other threads, which requires special handling in the serialization/deserialization mechanism that V8 does not provide for pure-JavaScript objects.
On the other hand, we now want MessagePort
to inherit from EventTarget
. There’s no easy way to get both because C++-backed classes (or at least the kind we’re concerned with here) cannot directly inherit from JS-defined classes.
One possible solution would be to move the entire EventTarget
and Event
implementation to C++. Browsers happily do this kind of switch; however, in Node.js, we prefer to use JavaScript to implement anything that can be implemented without C++. That makes it much more accessible to our users, so they can understand how it works, fix problems and add features themselves.
But, as you may remember, MessagePort
previously inherited from EventEmitter
. So can we possibly take the same approach here?
This was easy to implement with EventEmitter
because it is an “old-style” class, i.e. it was written before the ES6 class
keyword was introduced so we cannot switch it easily to use class
and maintain backwards compatibility. That’s nice because it means we can turn basically any object into an EventEmitter
after it has already been created:
This approach is not generally recommended, because it is easier to understand and often also faster when the type of an object is determined during its construction, but it worked for us in this case. It is, however, not directly applicable to EventTarget
, because that is written using ES6 class syntax, and one cannot use ClassName.call(object)
to invoke an ES6 class constructor on an arbitrary object.
Again, we could completely rewrite EventTarget
to use the pre-ES6 class syntax — but if you’ve ever had to use that, you know that it makes your code less clean. So that’s something we want to avoid. However, our solution follows a similar principle. We took out the constructor of EventTarget
, and put it into a separate function that we can call on each individual MessagePort
object, which conceptually looks like this:
In the end, this gets us close enough to where we want to be. It means that MessagePort
loses its previous superclass, but we actually want to get rid of that anyway because it’s not Web-compatible. (For those who wonder, it’s an internal C++ class that is used for all objects backed by libuv handles.)
Struggle 3: Performance
After the original pull request was opened to port MessagePort
over to EventTarget
, one thing became clear immediately: performance tanked, badly . The initial benchmark runs showed a performance loss of almost 75%. In other words, the new implementation was only a quarter as fast as the previous one. While this does not map 1:1 to real-world applications, because they will do more than just pass messages back and forth, it is a slowdown we want to avoid if at all possible.
When profiling the benchmark, we were somewhat surprised to find that the creation of the Event
objects used for the Web-style event listeners was the main source of the slowdown. Two particular JavaScript patterns looked particularly problematic: Our EventTarget
implementation was using private properties, and it was using Object.defineProperty()
in a non-ideal way.
The overhead of these operations showed visibly in the Flame graph we generated from the benchmark:
 Private properties are new
JavaScript engines usually implement a feature first without optimising its performance. Then in time, based on benchmarking and user feedback, we implement optimisations to make it faster.
Private properties are still a relatively new feature of JavaScript and, at least, in this case, each private property creation led to a round-trip to a built-in V8 method. You can actually figure out the number of private properties on the Event class just by looking at the Flame graph above!
Regular properties, however, have been around for a longer time, and engines know how to create these a lot faster. In this case, the improvement measured more than 75% over the private-properties implementation. Lucky for us, another pull request also ran into problems with the use of private properties for entirely unrelated reasons and switched them to be symbol-based. (If you’re curious, this was related to V8 snapshot support for faster Node.js process startup.)
Our Event
class ended up turning from this:
Into this:
As always, don’t over-optimise these things. Unless your bottleneck is from using private properties, there’s nothing against using them from a performance perspective.
Setting non-default property attributes is slow
The second problem was a bit harder to tackle: The specification for EventTarget
states that all Event
instances have a non-configurable, non-writable own property getter called isTrusted
. There’s very little documentation on this — MDN only indicates that it is used to figure out whether the event was user-generated. The fact that this is a non-modifiable property hints that there is probably some related security aspect, but it’s unclear what that is or how that concept would translate to Node.js. As a result, we always set it to false, and our Event
class looked like this:
When profiling, it turned out that when Object.defineProperty()
is used on an object to create a property with non-default attributes, the V8 engine allocates a special kind of store for these properties. Again, this was noticeably impacting performance.
If the mentioned possible security implications of this property don’t apply to Node.js, we could just break spec compliance and move the property to the Event
prototype. This would mean setting it only once instead of once for each object, completely alleviating the performance impact. However, it would also potentially allow code to change its value after the Event
has been created. Given the lack of clarity around the security implications, we decided not to go with this approach.
One interesting performance improvement was suggested after asking V8 engineers about this problem on Twitter. The above code always creates a new get function, which keeps V8 from interpreting the getter value as a constant. We switched to defining the function outside the class and always re-use the same one, and Event
creation performance became about three times as fast as it previously was — or in other words, a 200% performance boost.
Having a fast path beats improvements on the slow path
Even after this, we still had a significant slowdown. We were always creating Event
objects, and those weren’t plain JS objects, so there was an inevitable performance overhead. Ultimately, we rewrote NodeEventTarget
to give us a special internal way of dispatching events in two modes:
- Pass in the value that Node.js-style listeners should see and the
Event
object, - Pass in the value and create
Event
objects from that value on-demand for Web-style listeners.
MessagePort
uses the second mode, so extra objects are only created if there are Web-style listeners attached to it. Those who only use Node.js-style listeners can keep performance close to what they were used to, and those who want Web compatibility get it with a small performance tax.
As you can see (larger numbers are better), there’s around a 30% performance difference in the worst case. But be careful about over-optimising here. In most applications, it won’t make a noticeable difference which approaches you’re using, because your bottleneck is unlikely to be message passing.
This has been merged!
The pull request in question was released in Node.js 14.7.0, so have fun trying this out! Currently, Event
and EventTarget
are not official public APIs of Node.js, although that may change in the future. And the AbortController
implementation and its usage in some features, like cancelable timers, is scheduled to be part of Node.js 15.0.0.
Now that you have a good overview of the motivations and challenges behind implementing a Node.js feature like this — and some insight into the process of working through those — we hope you enjoy using the feature.
Insight, imagination and expertly engineered solutions to accelerate and sustain progress.
Contact