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.

A dial indicator on a cast stand, its stylus resting on a stack of four gauge blocks while a fifth block lies apart on the bench, with a thin yellow shim between the top two blocks.

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 -> N

Uninitialized 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:

js
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:

js
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->N

Same 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:

cpp
  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  POLYMORPHIC

Move 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:

cpp
#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=4

Node 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.7

Three 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.

Arrow keys to move, Enter to open.