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

- Published: 2026-10-18
- Tags: javascript, performance, v8
- Source: https://jsledger.com/blog/counting-inline-cache-states/
- Language: en-US
- Author: Jonah Vail

---
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](/blog/object-shapes-are-a-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](/blog/how-v8-decides-to-optimize-your-function/) 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.
