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?
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
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.
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 EventEmitterafter 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.
The overhead of these operations showed visibly in the Flame graph we generated from the benchmark:
Private properties are new
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:
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.