Skip to content

Build1 publisher3 min readPublished

Swapping null-prototype literals for classes doubled Node's WebStreams pipe throughput

Node core roughly doubled WebStreams pipe-to throughput by rebuilding four null-prototype state records as class instances. The slowdown matters only on hot paths, including constants built once and read on every write.

The Engineer · Build desk

What happened

  • Any object literal written with __proto__: null starts in V8's dictionary mode, whatever its property count or wherever the key sits, and reading it never moves it back.
  • Building such a literal takes roughly 500 to 1500 nanoseconds, against about 30 nanoseconds for the same literal without the null prototype.
  • Calling Object.setPrototypeOf on an existing plain literal, or using a class whose prototype has a null prototype, produces an object that stays in fast mode.
  • A second patch rebuilt two WritableStream sentinel objects with Object.setPrototypeOf and raised pipe-to throughput by 12.3% to 14.9%.

Compiled by The EngineerSomething wrong?How this is made

Why it matters

  • decision A cleanup should rank null-prototype objects by how often they are built or read, because descriptors passed once to Object.defineProperty cost nothing measurable in the author's tests.
  • exposure Module-level constants need the same scrutiny as per-call objects: a sentinel read several times per write keeps paying dictionary lookups for the life of the process, however rarely it is built.
  • capability Code that strips the prototype to keep Object.prototype out of lookups can keep that protection on hot paths and still get fast properties.

The evidence comes from one V8 intrinsic. Run with `--allow-natives-syntax`, `%HasFastProperties(o)` reports whether an object has fast properties [7]. The post's author checked a Node `main` build with V8 14.6.202.34-node.34 and Node v24.18.0, and both behaved the same [7]. In dictionary mode, an object's properties live in a hash table instead of behind a hidden class (a map, in V8's terms) [1]. The one exit is for the object to become another object's prototype. V8 then optimizes it as a prototype and switches it to fast mode [6].

The construction gap works out to roughly 17 to 50 times [1]. The repeating cost is on access: every property read is a dictionary lookup and never a fast inline-cache hit [4]. "Reading it a lot doesn't help; it stays slow for its whole life," the author wrote [5].

Round 16 of Node's WebStreams performance work (nodejs/node#65625) found four per-stream state records written as null-prototype literals [12]. They became instances of a class whose prototype has a null prototype, so each record's chain runs from the instance to `State.prototype` to null [9]. The fix for a slow object with no prototype turned out to be a longer prototype chain [9]. Pipe-to throughput rose 105% to 112% [12], or 2.05 to 2.12 times the old rate [2]. Stream creation sped up as well: WritableStream by 204%, ReadableStream by 138% and TransformStream by 134% [13].

Round 19 (nodejs/node#66230) dealt with objects built exactly once [14]. `kNilRequest` and `kNilPendingAbortRequest` are module-level sentinels in `lib/internal/webstreams/writablestream.js` [14]. They occupy the in-flight write, close and abort request slots whenever nothing is pending, and their `promise` field is checked several times per write [15]. Nothing ever used them as a prototype, so the one exit never applied [15]. After the patch, pipe-through rose 6.8% with a transform and writer-driven writes rose 6.5% to 17.6% [16]. In a live WritableStream, the probe reports the sentinels as DICT on `main` and FAST with the patch [17].

Of the three replacements the post lists, the plain literal is the only one that shares its map with other plain literals of the same shape [11]. It is safe only when every field the code reads is an own property. Own properties shadow `Object.prototype`, so a polluted prototype is never consulted [11]. `Object.setPrototypeOf({ ... }, null)` inherits the literal's fast map and then swaps the prototype, and the post recommends it for one-off objects [10]. For module constants like the sentinels, I think it is the right default. It is a one-line change, and it keeps the null prototype the original code asked for.

The percentages measure Node's own streams code [12][16]. For a similar gain elsewhere, the null-prototype object has to be created or read on a hot path, including long-lived shared constants that the hot path reads over and over [18]. The author wrote that readers should go after "the ones sitting on the path that runs a million times a second" [20]. The dictionary-mode behavior also has to hold on the V8 you ship. The probe is posted in the PR discussion and runs as `node --allow-natives-syntax nullproto.js` [19].

What to watch

  • A V8 release that creates null-prototype literals with fast properties would make both workarounds unnecessary; rerunning the nullproto.js probe on each Node upgrade would show it.
  • Later rounds of the WebStreams performance work could turn up more null-prototype constants on the read or write paths beyond the two sentinels in writablestream.js.
Loading claim ledger
Loading source directory links
Loading share composer
Loading topic controls
Loading related stories