Runtime and performanceThe V8 object modelPart 3
Four Maps Is Polymorphic. Five Is Not.
Monomorphic, polymorphic and megamorphic are counted states with a threshold of four. Here is the transition log, the V8 line that decides it, and the cost.

One property read, six object shapes, and the state of the call site printed at every change:
$ node --log-ic --logfile=ic.log --no-logfile-per-isolate ic.cjs # Node 26.8.1
$ grep '^LoadIC' ic.log | awk -F, '$9=="x"' | awk -F, '{print $6" -> "$7}'
0 -> 1
1 -> P
P -> P
P -> P
P -> N
N -> NUninitialized to monomorphic on the first map, polymorphic on the second, and megamorphic on the fifth. The adjectives people use for these sites are the names of counted states, and the count is four.
Reading the slot instead of the log
The log says what changed. %DebugPrint on the function says what the state is
now, which is the version you want when you are checking your own code:
function read(o) { return o.x; }
const warm = { x: 0 };
for (let i = 0; i < 50; i++) read(warm); // allocate the feedback vector first
const shapes = [{x:1,a:1},{x:1,b:1},{x:1,c:1},{x:1,d:1},{x:1,e:1}];
for (const s of shapes) { for (let i = 0; i < 20; i++) read(s); %DebugPrint(read); }$ node --allow-natives-syntax fv2.mjs | grep 'slot #0'
maps seen: 2 -> slot #0 LoadProperty POLYMORPHIC
maps seen: 3 -> slot #0 LoadProperty POLYMORPHIC
maps seen: 4 -> slot #0 LoadProperty POLYMORPHIC
maps seen: 5 -> slot #0 LoadProperty MEGAMORPHIC {
maps seen: 6 -> slot #0 LoadProperty MEGAMORPHIC {The warm-up loop is not decoration. Run the same script without it and the first
%DebugPrint reports No feedback vector, but we have a closure feedback cell array — V8 does not allocate a feedback vector until a function has been called
enough times, so a site examined too early has no state to read at all.
The shapes here are the ones the transition
tree hands out: same x, one different second
property each, five distinct maps.
Reads, keyed reads and writes all count to four
The ladder is not a property of o.x in particular. Instrument three call sites
in one script, a named load, a keyed load and a store, then feed all three the
same six shapes:
function read(o) { return o.x; } // LoadIC
function keyed(o, k) { return o[k]; } // KeyedLoadIC
function write(o) { o.x = 1; return o; } // StoreIC$ grep '^<kind>,' more.log | awk -F, '$9=="x"' | awk -F, '{printf "%s->%s ", $6, $7}'
LoadIC 0->1 1->P P->P P->P P->N N->N N->N
KeyedLoadIC 0->1 1->P P->P P->P P->N
StoreIC X->X 0->1 1->P P->P P->P P->N N->N N->NSame ladder, same fifth-map transition, three different caches. The X at the
head of the store line is the no-feedback state, which is what a site reports
before it has a vector at all.
The practical version: a “type” that reaches your code through four different object literals is fine, and the fifth one is not, whether the code reads a property, reads a computed key, or writes.
The line that decides it
IC::UpdatePolymorphicIC in src/ic/ic.cc is where the count is enforced, and
the check itself is three lines:
int number_of_valid_maps =
number_of_maps - deprecated_maps - (handler_to_overwrite != -1);
if (number_of_valid_maps >= v8_flags.max_valid_polymorphic_map_count) {
return false;
}Returning false is how the IC says “I cannot extend the polymorphic list”,
which is what sends the site to megamorphic. The ceiling is not a constant baked
into the shape of the feedback vector; it is v8_flags, a runtime flag with a
default.
That distinction is testable, and it is the strongest evidence that these states are counted rather than qualitative:
$ node --allow-natives-syntax --max-valid-polymorphic-map-count=2 fv2.mjs
maps seen: 2 POLYMORPHIC maps seen: 3 MEGAMORPHIC maps seen: 4 MEGAMORPHIC
$ node --allow-natives-syntax --max-valid-polymorphic-map-count=6 fv2.mjs
maps seen: 2 POLYMORPHIC maps seen: 5 POLYMORPHIC maps seen: 6 POLYMORPHICMove the number, the transition moves with it, exactly where the source says it should. Nothing else about the program changed.
The number in the source is not the number in your build
Here is where reading V8’s source to explain Node’s behavior goes wrong.
src/flags/flag-definitions.h on V8’s main branch, retrieved 2026-09-04, says:
#define DEFAULT_MAX_POLYMORPHIC_MAP_COUNT 10
DEFINE_INT(max_valid_polymorphic_map_count, DEFAULT_MAX_POLYMORPHIC_MAP_COUNT,
"maximum number of valid maps to track in POLYMORPHIC state")Ten. The binary in front of me disagrees:
$ node --v8-options | grep -A2 max-valid-polymorphic
--max-valid-polymorphic-map-count (maximum number of valid maps to track in POLYMORPHIC state)
type: int default: --max-valid-polymorphic-map-count=4Node 26.8.1 ships V8 14.6.202.34, which branched before that default moved.
Ask the binary. Every article that quotes “four” as a fact about V8 — this
one included — is quoting a flag default from one build, and --v8-options is a
one-line way to check whether it is still yours.
What the cliff costs
Two million property reads through the same function, with the objects cycling through a fixed number of maps, medians of seven runs:
$ node cost.mjs <shapes> # 2,000,000 reads, Node 26.8.1
1 shapes | median 2.06 ms | 2.0 2.0 2.0 2.1 2.9 5.0 5.2
2 shapes | median 1.94 ms | 1.9 1.9 1.9 1.9 3.1 3.7 4.9
4 shapes | median 2.71 ms | 2.4 2.5 2.5 2.7 3.4 4.1 5.4
5 shapes | median 9.61 ms | 5.6 6.3 7.9 9.6 9.7 9.8 9.9
8 shapes | median 9.63 ms | 5.9 7.1 8.1 9.6 9.7 11.9 14.7Three things in that table are worth saying out loud. The step from four maps to five is a cliff, roughly three and a half times, and it lands exactly where the flag said it would. The step from five to eight is not a step at all — once the site is megamorphic, more shapes cost nothing extra, because the site already gave up on remembering any of them. And one shape measured marginally slower than two, which is noise: the spreads overlap, and anyone reporting monomorphic as slower than polymorphic from a difference like that is reporting their machine.
Now the honest part. The gap is 6.9 ms across two million reads, which is about 3.5 nanoseconds per read. In application code that number disappears behind one layout thrash, one JSON parse, one unnecessary re-render. The reason to know the threshold is not to shave nanoseconds; it is that a megamorphic site cannot be inlined against a known map, so the cost that matters is usually the optimization that never happens rather than the reads themselves.
What to check, and what I did not
Three commands cover it. node --v8-options | grep max-valid-polymorphic for
your build’s ceiling. --log-ic with a $9=="x" filter for the ladder a
specific property walks. %DebugPrint(fn) under --allow-natives-syntax for
the state a site is in right now — remembering that a site called fewer than a
few dozen times has no feedback vector to report.
One question worth settling before you plan around it: a megamorphic site does not heal. Push a call site past the threshold, then call it two hundred thousand times with a single shape and ask again.
$ node --allow-natives-syntax recover.mjs
after 5 maps -> slot #0 LoadProperty MEGAMORPHIC {
after 200k mono -> slot #0 LoadProperty MEGAMORPHIC {The state belongs to the call site, not to the objects currently flowing through it. Whatever put a fifth map through that line — a test fixture, a code path that runs once at startup, a polyfill — has spent the site permanently for the lifetime of that function’s feedback vector.
What I did not test: whether a new feedback vector, allocated for a fresh
closure over the same source, starts clean. That is the mechanism a long-running
server would depend on, and it deserves its own measurement rather than an
assumption here. --trace-ic, which older write-ups use, is not available in
Node 26.8.1 (node: bad option); --log-ic is what replaced it, and it writes a
full V8 log you have to filter down to the one call site you care about.
FAQ
Frequently asked
How many object shapes can a call site handle before it goes megamorphic?
Four, on Node 26.8.1. The fifth distinct map trips the transition. This is V8 flag max_valid_polymorphic_map_count, and you can confirm your own build with node --v8-options | grep max-valid-polymorphic.
Does a megamorphic call site recover if the extra shapes stop appearing?
No. Measured on Node 26.8.1, a site pushed to megamorphic by five maps was still megamorphic after 200,000 further calls with a single shape. The state belongs to the call site, not to the objects currently flowing through it.


