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:

// Create an EventTarget, register a listener, and dispatch an event:
const target = new EventTarget();
target.addEventListener('click', (event) => {
  // Prints Event { type: 'click', x: 128, y: 23, ... }
  console.log(event);
});
// Or, as an alternative way of registering a single listener:
target.onclick = (event) => console.log(event);
target.dispatchEvent(new Event('click'));

// Create an EventEmitter, register a listener, and dispatch an event:
const emitter = new EventEmitter();
emitter.on('click', (object) => {
  // Prints { x: 128, y: 23 }
  console.log(object);
});
emitter.emit('click', { x: 128, y: 23 });

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:

const { port1, port2 } = new MessageChannel();
port1.on('message',
  (data) => console.log('Node.js style listener', data));
port1.addEventListener('message',
  (event) => console.log('Web style I listener', event.data));
port1.onmessage =
  (event) => console.log('Web style II listener', event.data);

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:

class NotAnEventEmitter {}
const obj = new NotAnEventEmitter();

// ✨ Magic inheritance change ✨
EventEmitter.call(obj);
Object.setPrototypeOf(obj, EventEmitter.prototype);

// obj is now an EventEmitter:
obj.on('message', console.log);
obj.emit('message', 'Hello, world!');

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:

function initEventTarget(self) {
  self.events = new Map();
  …
}

class EventTarget {
  constructor() {
    initEventTarget(this);
  }

  addEventListener(type, listener, options = {}) {
    …
  }

 …
}

Object.setPrototypeOf(MessagePort.prototype, EventTarget.prototype);
MessagePort.prototype[special_oninit_symbol] = function () {
  // This is a special function that is called on each MessagePort
  // when it is created.
  initEventTarget(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:

Node.js and the struggles of being an EventTarget


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:

class Event {
  #type = undefined;
  #defaultPrevented = false;
  #cancelable = false;
  …

  constructor(type, options) {
    …
    this.#type = `${type}`;
    this.#cancelable = !!options.cancelable;
    …
  }
  …
}

Into this:

const kType = Symbol('type');
const kDefaultPrevented = Symbol('defaultPrevented');
const kCancelable = Symbol('cancelable');
…
class Event {
  constructor(type, options) {
    …
    this[kType] = `${type}`;
    this[kCancelable] = !!options.cancelable;
    this[kDefaultPrevented] = false;
    …
  }
  …
}

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:

class Event {
 constructor(type, options) {
  …
  // isTrusted is special (LegacyUnforgeable)
  Object.defineProperty(this, 'isTrusted', {
    get() { return false; },
    enumerable: true,
    configurable: false
  });
  …
 }
}

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.

$ ./node benchmark/worker/messageport.js
worker/messageport.js n=1000000 style="eventtarget" payload="string": 514,343.4399611038
worker/messageport.js n=1000000 style="eventemitter" payload="string": 733,899.6080202564
worker/messageport.js n=1000000 style="eventtarget" payload="object": 315,239.25714214554
worker/messageport.js n=1000000 style="eventemitter" payload="object": 423,738.7049852787

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.

View all posts  |  Technology  |  Business  |  Culture  |  Opinion  |  Design
Follow us for more information on this and other topics.
Published by Anna Henningsen