#include <node_api.h>
#include <stdlib.h>
enum { kWithProperties, kSetProperty, kDefineProperties };
// make(names, values, prototype, count[, how]): `count` objects, each from one
// node_api_create_object_with_properties call (an undefined prototype passes NULL), or with
// `how` 1 from napi_create_object and one napi_set_property call per property, or with `how` 2
// from napi_create_object and one napi_define_properties call (the prototype is ignored for both).
static napi_value Make(napi_env env, napi_callback_info info) {
size_t argc = 5;
napi_value argv[5], result, proto;
uint32_t width, count;
int32_t how = kWithProperties;
napi_valuetype type;
napi_get_cb_info(env, info, &argc, argv, NULL, NULL);
napi_get_array_length(env, argv[0], &width);
napi_get_value_uint32(env, argv[3], &count);
napi_typeof(env, argv[2], &type);
proto = type == napi_undefined ? NULL : argv[2];
if (argc > 4) napi_get_value_int32(env, argv[4], &how);
napi_value* names = malloc(sizeof(napi_value) * width * 2);
napi_value* values = names + width;
napi_property_descriptor* descriptors = calloc(width, sizeof(napi_property_descriptor));
for (uint32_t i = 0; i < width; i++) {
napi_get_element(env, argv[0], i, &names[i]);
napi_get_element(env, argv[1], i, &values[i]);
descriptors[i].name = names[i];
descriptors[i].value = values[i];
descriptors[i].attributes = napi_default_jsproperty;
}
napi_create_array_with_length(env, count, &result);
for (uint32_t i = 0; i < count; i++) {
napi_handle_scope scope;
napi_value obj;
napi_open_handle_scope(env, &scope);
if (how == kSetProperty) {
napi_create_object(env, &obj);
for (uint32_t j = 0; j < width; j++) napi_set_property(env, obj, names[j], values[j]);
} else if (how == kDefineProperties) {
napi_create_object(env, &obj);
napi_define_properties(env, obj, width, descriptors);
} else {
node_api_create_object_with_properties(env, proto, names, values, width, &obj);
}
napi_set_element(env, result, i, obj);
napi_close_handle_scope(env, scope);
}
free(descriptors);
free(names);
return result;
}
NAPI_MODULE_INIT(/* napi_env env, napi_value exports */) {
napi_value fn;
napi_create_function(env, "make", NAPI_AUTO_LENGTH, Make, NULL, &fn);
napi_set_named_property(env, exports, "make", fn);
return exports;
}
'use strict';
// One case per process, so the cases do not share a heap:
// for c in literal setters define proto null; do node --allow-natives-syntax repro.js $c 10; done
// for c in today direct cached today-null cached-null; do node --allow-natives-syntax repro.js $c 10; done
const { make } = require('./build/Release/binding.node');
const { makeV8 } = require('./build/Release/cached.node');
const which = process.argv[2];
const width = Number(process.argv[3] ?? 10);
const count = 100_000;
const names = Array.from({ length: width }, (_, i) => `k${i}`);
const values = names.map((_, i) => i + 0.5);
// Every case stores the same values; only the way the objects are made differs.
const literals = new Function('n', 'v', `
const a = new Array(n);
for (let i = 0; i < n; i++) a[i] = { ${names.map((k, j) => `${k}: v[${j}]`).join(', ')} };
return a;`);
const read = new Function('a', `
let s = 0;
for (let i = 0; i < a.length; i++) { const o = a[i]; s += ${names.map((k) => `o.${k}`).join(' + ')}; }
return s;`);
const build = {
// Node-API, binding.c
literal: () => literals(count, values),
setters: () => make(names, values, undefined, count, 1),
define: () => make(names, values, undefined, count, 2),
proto: () => make(names, values, Object.prototype, count),
null: () => make(names, values, undefined, count),
// V8 API, cached.cc: one harness for today's body, Object::New alone and the cache
today: () => makeV8(names, values, Object.prototype, count, 0),
direct: () => makeV8(names, values, Object.prototype, count, 1),
cached: () => makeV8(names, values, Object.prototype, count, 2),
'today-null': () => makeV8(names, values, undefined, count, 0),
'cached-null': () => makeV8(names, values, undefined, count, 2),
}[which];
// Median ns per property of `runs` calls of f; returns the last result too.
function time(f, runs) {
const times = [];
let out;
for (let i = 0; i < runs; i++) {
const t0 = process.hrtime.bigint();
out = f();
times.push(Number(process.hrtime.bigint() - t0));
}
times.sort((x, y) => x - y);
return [times[times.length >> 1] / (count * width), out];
}
build();
const [buildNs, objects] = time(build, 11);
for (let i = 0; i < 5; i++) read(objects);
const [readNs] = time(() => read(objects), 21);
const fast = %HasFastProperties(objects[0]);
const sameMap = %HaveSameMap(objects[0], objects[count - 1]);
console.log(
`${process.version} ${which.padEnd(11)} ${width} properties: fast=${fast} sameMap=${sameMap} ` +
`build ${buildNs.toFixed(1)} ns, read ${readNs.toFixed(2)} ns per property`,
);
# start 2026-10-01 10:48:03 10:48 up 11 days, 23:42, 5 users, load averages: 36.82 40.10 45.37
## round 1 10:48 up 11 days, 23:42, 5 users, load averages: 36.82 40.10 45.37
v26.10.0 literal 10 properties: fast=true sameMap=true build 4.1 ns, read 0.67 ns per property
v26.10.0 setters 10 properties: fast=true sameMap=true build 117.9 ns, read 0.83 ns per property
v26.10.0 define 10 properties: fast=true sameMap=true build 70.0 ns, read 0.76 ns per property
v26.10.0 proto 10 properties: fast=false sameMap=true build 34.0 ns, read 5.61 ns per property
v26.10.0 null 10 properties: fast=false sameMap=false build 45.0 ns, read 51.75 ns per property
v26.10.0 today 10 properties: fast=false sameMap=true build 28.6 ns, read 5.46 ns per property
v26.10.0 direct 10 properties: fast=false sameMap=true build 28.1 ns, read 5.73 ns per property
v26.10.0 cached 10 properties: fast=true sameMap=true build 27.4 ns, read 0.86 ns per property
v26.10.0 today-null 10 properties: fast=false sameMap=false build 35.8 ns, read 51.52 ns per property
v26.10.0 cached-null 10 properties: fast=true sameMap=true build 49.3 ns, read 0.70 ns per property
v26.10.0 literal 32 properties: fast=true sameMap=true build 3.8 ns, read 0.54 ns per property
v26.10.0 setters 32 properties: fast=false sameMap=true build 128.9 ns, read 5.83 ns per property
v26.10.0 define 32 properties: fast=true sameMap=true build 69.2 ns, read 0.67 ns per property
v26.10.0 proto 32 properties: fast=false sameMap=true build 27.4 ns, read 5.65 ns per property
v26.10.0 null 32 properties: fast=false sameMap=false build 29.1 ns, read 51.12 ns per property
v26.10.0 today 32 properties: fast=false sameMap=true build 26.8 ns, read 5.65 ns per property
v26.10.0 direct 32 properties: fast=false sameMap=true build 25.6 ns, read 6.77 ns per property
v26.10.0 cached 32 properties: fast=true sameMap=true build 20.5 ns, read 0.53 ns per property
v26.10.0 today-null 32 properties: fast=false sameMap=false build 26.1 ns, read 51.61 ns per property
v26.10.0 cached-null 32 properties: fast=true sameMap=true build 41.0 ns, read 0.51 ns per property
## round 2 10:48 up 11 days, 23:43, 5 users, load averages: 37.54 39.94 45.07
v26.10.0 literal 10 properties: fast=true sameMap=true build 3.9 ns, read 0.69 ns per property
v26.10.0 setters 10 properties: fast=true sameMap=true build 117.0 ns, read 0.76 ns per property
v26.10.0 define 10 properties: fast=true sameMap=true build 69.3 ns, read 0.75 ns per property
v26.10.0 proto 10 properties: fast=false sameMap=true build 32.9 ns, read 5.86 ns per property
v26.10.0 null 10 properties: fast=false sameMap=false build 46.4 ns, read 53.03 ns per property
v26.10.0 today 10 properties: fast=false sameMap=true build 29.8 ns, read 5.76 ns per property
v26.10.0 direct 10 properties: fast=false sameMap=true build 26.0 ns, read 5.76 ns per property
v26.10.0 cached 10 properties: fast=true sameMap=true build 29.4 ns, read 0.71 ns per property
v26.10.0 today-null 10 properties: fast=false sameMap=false build 37.6 ns, read 51.77 ns per property
v26.10.0 cached-null 10 properties: fast=true sameMap=true build 50.0 ns, read 0.71 ns per property
v26.10.0 literal 32 properties: fast=true sameMap=true build 3.7 ns, read 0.53 ns per property
v26.10.0 setters 32 properties: fast=false sameMap=true build 127.3 ns, read 7.76 ns per property
v26.10.0 define 32 properties: fast=true sameMap=true build 71.0 ns, read 0.83 ns per property
v26.10.0 proto 32 properties: fast=false sameMap=true build 26.5 ns, read 6.01 ns per property
v26.10.0 null 32 properties: fast=false sameMap=false build 28.0 ns, read 51.38 ns per property
v26.10.0 today 32 properties: fast=false sameMap=true build 26.4 ns, read 7.11 ns per property
v26.10.0 direct 32 properties: fast=false sameMap=true build 25.1 ns, read 5.79 ns per property
v26.10.0 cached 32 properties: fast=true sameMap=true build 20.4 ns, read 0.64 ns per property
v26.10.0 today-null 32 properties: fast=false sameMap=false build 27.5 ns, read 52.30 ns per property
v26.10.0 cached-null 32 properties: fast=true sameMap=true build 39.7 ns, read 0.53 ns per property
## round 3 10:49 up 11 days, 23:44, 5 users, load averages: 37.83 39.76 44.77
v26.10.0 cached-null 10 properties: fast=true sameMap=true build 49.0 ns, read 0.69 ns per property
v26.10.0 today-null 10 properties: fast=false sameMap=false build 34.9 ns, read 51.50 ns per property
v26.10.0 cached 10 properties: fast=true sameMap=true build 27.3 ns, read 0.70 ns per property
v26.10.0 direct 10 properties: fast=false sameMap=true build 27.1 ns, read 6.74 ns per property
v26.10.0 today 10 properties: fast=false sameMap=true build 30.3 ns, read 6.51 ns per property
v26.10.0 null 10 properties: fast=false sameMap=false build 39.4 ns, read 52.18 ns per property
v26.10.0 proto 10 properties: fast=false sameMap=true build 35.8 ns, read 5.63 ns per property
v26.10.0 define 10 properties: fast=true sameMap=true build 70.7 ns, read 0.82 ns per property
v26.10.0 setters 10 properties: fast=true sameMap=true build 116.6 ns, read 0.84 ns per property
v26.10.0 literal 10 properties: fast=true sameMap=true build 4.1 ns, read 0.70 ns per property
v26.10.0 cached-null 32 properties: fast=true sameMap=true build 41.0 ns, read 0.54 ns per property
v26.10.0 today-null 32 properties: fast=false sameMap=false build 29.0 ns, read 54.10 ns per property
v26.10.0 cached 32 properties: fast=true sameMap=true build 20.5 ns, read 0.52 ns per property
v26.10.0 direct 32 properties: fast=false sameMap=true build 24.5 ns, read 6.25 ns per property
v26.10.0 today 32 properties: fast=false sameMap=true build 26.9 ns, read 6.10 ns per property
v26.10.0 null 32 properties: fast=false sameMap=false build 30.2 ns, read 51.95 ns per property
v26.10.0 proto 32 properties: fast=false sameMap=true build 25.6 ns, read 5.78 ns per property
v26.10.0 define 32 properties: fast=true sameMap=true build 70.0 ns, read 0.74 ns per property
v26.10.0 setters 32 properties: fast=false sameMap=true build 127.2 ns, read 5.46 ns per property
v26.10.0 literal 32 properties: fast=true sameMap=true build 4.0 ns, read 0.53 ns per property
# end 2026-10-01 10:50:00 10:50 up 11 days, 23:44, 5 users, load averages: 37.90 39.55 44.46
# 2026-10-01 10:50:00 10:50 up 11 days, 23:44, 5 users, load averages: 37.90 39.55 44.46
$ for w in 10 32; do for c in literal setters define proto null; do node --allow-natives-syntax repro.js $c $w; done; done
v26.10.0 literal 10 properties: fast=true sameMap=true build 3.9 ns, read 0.70 ns per property
v26.10.0 setters 10 properties: fast=true sameMap=true build 120.1 ns, read 0.81 ns per property
v26.10.0 define 10 properties: fast=true sameMap=true build 82.5 ns, read 0.99 ns per property
v26.10.0 proto 10 properties: fast=false sameMap=true build 51.2 ns, read 6.94 ns per property
v26.10.0 null 10 properties: fast=false sameMap=false build 59.9 ns, read 52.70 ns per property
v26.10.0 literal 32 properties: fast=true sameMap=true build 4.0 ns, read 0.75 ns per property
v26.10.0 setters 32 properties: fast=false sameMap=true build 126.5 ns, read 6.83 ns per property
v26.10.0 define 32 properties: fast=true sameMap=true build 72.3 ns, read 0.86 ns per property
v26.10.0 proto 32 properties: fast=false sameMap=true build 44.0 ns, read 6.69 ns per property
v26.10.0 null 32 properties: fast=false sameMap=false build 30.9 ns, read 54.22 ns per property
A_EXIT=0
$ for w in 10 32; do for c in today direct cached today-null cached-null; do node --allow-natives-syntax repro.js $c $w; done; done
v26.10.0 today 10 properties: fast=false sameMap=true build 30.1 ns, read 6.94 ns per property
v26.10.0 direct 10 properties: fast=false sameMap=true build 29.8 ns, read 6.32 ns per property
v26.10.0 cached 10 properties: fast=true sameMap=true build 26.7 ns, read 0.71 ns per property
v26.10.0 today-null 10 properties: fast=false sameMap=false build 35.7 ns, read 51.41 ns per property
v26.10.0 cached-null 10 properties: fast=true sameMap=true build 48.1 ns, read 0.73 ns per property
v26.10.0 today 32 properties: fast=false sameMap=true build 23.4 ns, read 6.84 ns per property
v26.10.0 direct 32 properties: fast=false sameMap=true build 22.9 ns, read 5.54 ns per property
v26.10.0 cached 32 properties: fast=true sameMap=true build 20.9 ns, read 0.50 ns per property
v26.10.0 today-null 32 properties: fast=false sameMap=false build 26.2 ns, read 53.22 ns per property
v26.10.0 cached-null 32 properties: fast=true sameMap=true build 38.8 ns, read 0.57 ns per property
B_EXIT=0
# 2026-10-01 10:50:41 10:50 up 11 days, 23:45, 5 users, load averages: 38.41 39.55 44.24
What is the problem this feature will solve?
The new
node_api_create_object_with_properties(#59953) always returns objects in dictionary mode. It callsv8::Object::New(isolate, prototype, names, values, count), and V8 builds a dictionary there whatever the shape. So the objects are slow to read: about 8x slower than an object literal withObject.prototype, and about 75x with aNULLprototype, because then every object also gets its own map.napi_create_objectplusnapi_define_propertiesgives fast objects, but it costs about twice as much to build. So right now an addon can have cheap object creation or fast property access, not both.I measured it with a small addon: 10 properties, 100,000 objects, Node 26.10.0 on an M1 Max, median of 4 runs. The machine was busy, so the ratios matter more than the absolute numbers. Reads are compared with the object literal, builds with
node_api_create_object_with_propertiesandObject.prototype.napi_set_propertyper propertynapi_define_propertiesnode_api_create_object_with_properties,Object.prototypenode_api_create_object_with_properties,NULLAt 32 properties it looks the same, except that the
napi_set_propertyobjects are in dictionary mode too.Repro and raw output
{ "targets": [ { "target_name": "binding", "sources": ["binding.c"], "defines": ["NAPI_EXPERIMENTAL"] }, { "target_name": "cached", "sources": ["cached.cc"], "cflags_cc": ["-std=c++20"], "xcode_settings": { "CLANG_CXX_LANGUAGE_STANDARD": "c++20" } } ] }napi-rs uses this call for every
#[napi(object)]value when itsexperimentalfeature is on (napi-rs/napi-rs#3559), so all of those objects are affected.What is the feature you are proposing to solve the problem?
Keep the API as it is, and have Node cache a
v8::DictionaryTemplateper list of property names in thenapi_env. When a list of names comes in a second time, create a template for it, and from then on create the objects withNewInstance. They come out fast and share one map. Node core already creates objects this way in a few places (#59802, #60033, #61432, and #66385 fornode:sqliterows).I tried this in an addon against the V8 API (
cached.ccbelow), next to a copy of today's implementation in the same harness. Same comparisons as above, with builds compared with today's implementation:Object.prototypeObject.prototypeNULLNULLReads go back to object-literal speed, and building gets a bit cheaper. With any prototype other than
Object.prototypebuilding gets more expensive, because each object then needs aSetPrototype(core'sNewDictionaryInstanceNullProtodoes the same).A real implementation would also have to handle:
DictionaryTemplate::Newtakes the names as one-byte strings, and it aborts on array-index names like"0". So symbols, two-byte names, index names and duplicates have to keep usingObject::New.cached.ccis just a 256-entry direct-mapped table.cached.cc
What alternatives have you considered?
An explicit template API: create a template from a list of names, create objects from it, delete it. That avoids hashing the names on every call and needs no cache policy, but it's a new API, and it only helps addons that switch to it.
Changing V8 so that
Object::Newreturns fast objects would be another way, but dictionary mode is how that API is designed.Would the team prefer the cache inside the existing call, or a template API? I can open a PR for either.
AI disclosure: I used a coding agent for the investigation, the reproduction and the text of this issue. I checked the results and will answer comments myself.