Optimizing objects with null prototypes
I dug into why `{ __proto__: null }` traps objects in V8's dictionary mode forever, and show how swapping those literals for classes or `Object.setPrototypeOf` doubled WebStreams throughput in Node core.
Hey Everyone!
This week I got deep into a V8 detail that ended up costing us real, measurable throughput in Node core — and I want to walk you through it, because the fix is not what most people would reach for.
It all starts with { __proto__: null }. You've probably used it a thousand times to build a clean "map"-like object that doesn't inherit from Object.prototype. It feels safe and idiomatic. Here's the catch: in V8, any object created with a null prototype starts in dictionary mode.
That means its properties live in a hash table instead of behind a hidden class (map). Two costs: building the literal takes roughly 500–1500 ns versus about 30 ns for the same literal without __proto__: null. And every property access afterwards is a dictionary lookup — never a fast inline-cache hit. Reading it a lot doesn't help; it stays slow for its whole life.
The one exception? If it's used as another object's prototype, V8 optimizes it as a prototype and switches it to fast mode.
I verified all of this against a Node main build (V8 14.6.202.34-node.34) and Node v24.18.0, using %HasFastProperties(o) with --allow-natives-syntax. Both behave the same.
| How the object is created | Right after creation | After 100k property reads | After being used as a prototype |
|---|---|---|---|
| { __proto__: null } (empty) | DICT | DICT | FAST |
| { __proto__: null, a, b, c } | DICT | DICT | FAST |
| { a, b, c, _proto__: null } (__proto` written last) | DICT | DICT | FAST |
| { __proto__: null, ...20 props } | DICT | DICT | FAST |
| Object.create(null), with or without props added later | DICT | DICT | FAST |
| Object.setPrototypeOf({ a, b, c }, null) | FAST | FAST | FAST |
| class instance whose prototype has a null prototype | FAST | FAST | FAST |
| { __proto__: someObject, a } (non-null prototype) | FAST | FAST | FAST |
| plain { a, b, c } | FAST | FAST | FAST |
| Object.freeze({ a, b, c }) | FAST | FAST | FAST |
Here is a summary of those results:
- Any literal with
__proto__: nullat creation time → DICT, no matter how many props, no matter where__proto__sits. - Reading properties never brings it back to fast mode. Only using it as a prototype does.
Object.setPrototypeOf({ a, b, c }, null)on an existing plain object keeps it fast.- A class instance whose
prototypehas a null prototype is fast.
Those last two are the trick. Here are the three fast replacements that keep Object.prototype out of lookups:
- A class whose prototype has a null prototype — for per-instance state records.
new State()gives you a fast instance with the chaininstance → State.prototype → null. Object.setPrototypeOf({ ... }, null)— for one-off objects. Inherits the literal's fast map, swaps the prototype.- A plain literal — when every field you read is an own property. Own props shadow
Object.prototype, so a polluted prototype is never consulted. This is the only one that lets the object share its map with other plain literals of the same shape.
Let's fix this
The webstreams performance work in Node core hit this twice.
Round 16 (nodejs/node#65625): four per-stream state records were null-prototype literals. Converting them to option 1 roughly doubled pipe-to throughput (+105–112%). WritableStream creation became +204% faster, ReadableStream +138%, TransformStream +134%.
Round 19 (nodejs/node#66230): two module-level sentinels in lib/internal/webstreams/writablestream.js — kNilRequest and kNilPendingAbortRequest — were null-prototype literals. They're created once, but they sit in the in-flight write, close, and abort request slots whenever nothing is pending, and their promise field gets checked several times per write. Nothing ever uses them as a prototype, so they stayed in dictionary mode for the life of the process. Building them as plain literals and nulling the prototype afterwards (option 2) gave pipe-to +12.3% to +14.9%, pipe-through +6.8% with a transform, and writer-driven writes +6.5% to +17.6%.
In a live WritableStream, %HasFastProperties reports these sentinels as DICT on main and FAST with the patch.
The takeaway
A null-prototype object only matters for performance when it's created or read on a hot path. That includes long-lived shared constants that the hot path reads over and over. Descriptors passed once to Object.defineProperty and similar one-time uses cost nothing measurable. So don't go ripping out every __proto__: null you see — go after the ones sitting on the path that runs a million times a second.
Reproducing
You can reproduce the whole table yourself. The probe is up in the PR discussion; run it with node --allow-natives-syntax nullproto.js:
'use strict';
const cases = {
'literal {__proto__: null} (empty)': () => ({ __proto__: null }),
'literal {__proto__: null, a, b, c}': () => ({ __proto__: null, a: 1, b: 2, c: 3 }),
'literal {a, b, c, __proto__: null} (proto last)': () => ({ a: 1, b: 2, c: 3, __proto__: null }),
'literal {__proto__: null, 20 props}': () => ({ __proto__: null, p0: 0, p1: 1, p2: 2, p3: 3, p4: 4, p5: 5, p6: 6, p7: 7, p8: 8, p9: 9, p10: 0, p11: 1, p12: 2, p13: 3, p14: 4, p15: 5, p16: 6, p17: 7, p18: 8, p19: 9 }),
'Object.create(null)': () => Object.create(null),
'Object.create(null) + 3 props added': () => { const o = Object.create(null); o.a = 1; o.b = 2; o.c = 3; return o; },
'plain {a, b, c} then setPrototypeOf(null)': () => Object.setPrototypeOf({ a: 1, b: 2, c: 3 }, null),
'class instance, prototype null-protoed': (() => { class C { a = 1; b = 2; c = 3; } Object.setPrototypeOf(C.prototype, null); return () => new C(); })(),
'literal {__proto__: someObject, a}': (() => { const p = { x: 1 }; return () => ({ __proto__: p, a: 1 }); })(),
'plain {a, b, c}': () => ({ a: 1, b: 2, c: 3 }),
'Object.freeze(plain)': () => Object.freeze({ a: 1, b: 2, c: 3 }),
};
for (const [name, make] of Object.entries(cases)) {
const o = make();
const fresh = %HasFastProperties(o);
for (let i = 0; i < 1e5; i++) { const x = o.a; }
make(); make();
const later = %HasFastProperties(make());
const afterUseAsProto = (() => { Object.create(o); for (let i = 0; i < 20; i++) Object.create(o).a; return %HasFastProperties(o); })();
console.log(name.padEnd(48), 'fresh:', fresh ? 'FAST' : 'DICT', ' after warmup:', later ? 'FAST' : 'DICT', ' after used as prototype:', afterUseAsProto ? 'FAST' : 'DICT');
}
And to check the real sentinel in a given node build:
// node --allow-natives-syntax check.js
const ws = new WritableStream();
const kState = Object.getOwnPropertySymbols(ws)
.find((s) => s.description === 'kState');
console.log(%HasFastProperties(ws[kState].inFlightWriteRequest) ? 'FAST' : 'DICT');
Big thanks to everyone who keeps digging into the V8 internals with me on these WebStreams PRs: the numbers don't lie, and this one was a nice reminder that the cheapest win is sometimes how you create an object, not the work you do on it afterwards.
Thanks!