open atlas
↑ Back to track
JavaScript Engine internals JSE · 03 · 02

Transition trees, deprecation, and migration

Adding a property follows or creates a transition edge to a new Map; the tree branches by addition order, by elements-kind change, and by field-representation generalization. When a representation must widen across instances

JSE Middle ◷ 15 min
Level
FoundationsJuniorMiddleSenior

You already know “property order matters” from the overview. But here is the part that surprises seniors: a Map can become deprecated — marked dead — while objects are still pointing at it, and V8 quietly rewrites those objects to a newer layout the next time you touch them. Assigning 2.5 to a field that used to hold integers can silently rebuild the shape of every object that shares it. This lesson is how the shape graph grows, mutates, and heals.

Transitions are edges in a shared tree

Recall the one-line version from browser/03-v8-internals/03-hidden-classes: adding x then y reaches a different leaf than adding y then x, so order matters. Now the mechanism. Every Map stores a forward TransitionArray keyed by {property name, attributes}. Adding a property:

  1. Looks up the {name, attributes} edge in the current Map’s TransitionArray.
  2. If an edge exists, follows it — no allocation, you reuse the existing child Map and its (shared) DescriptorArray.
  3. If no edge exists, creates a new child Map (extending the parent’s DescriptorArray by one descriptor), records the edge, and points the object at the new Map.

Because the tree is shared across the entire isolate, the second object to walk a path is free — it follows edges the first object created. This is why building objects the same way is cheap and building them in scrambled orders is not: scrambled orders never re-find an edge, so every object forces a fresh Map.

const a = {}; a.x = 1; a.y = 2;   // empty -> +x -> +x,y   (creates 2 edges)
const b = {}; b.x = 3; b.y = 4;   // empty -> +x -> +x,y   (follows them; 0 new Maps)
const c = {}; c.y = 5; c.x = 6;   // empty -> +y -> +y,x   (new branch; 2 new Maps)

Three more ways to transition (not just adding properties)

The overview implied transitions come only from adding properties. They do not. There are three other triggers, and they bite in production:

  • Elements-kind change. Writing a float into a PACKED_SMI_ELEMENTS array, or creating a hole, transitions the array’s Map to a wider elements kind (PACKED_SMI → PACKED_DOUBLE → PACKED_ELEMENTS, and the HOLEY variants). One-way, like the property tree.
  • Field representation generalization. This is the big one. A field starts narrow — say Smi. If any instance later stores a value that does not fit (2.5, or a string), the field’s representation must widen to Double or Tagged. But the representation is a property of the Map, shared by all instances. So V8 cannot just change one object; it must generalize the Map.
  • Constness loss. A field is const while every instance assigned it the same value. The first instance that assigns a different value flips the descriptor to mutable, which is itself a transition (the compiled code that inlined the constant must be invalidated).

Together these three non-adding triggers share one pattern: a change in what a field can hold forces a change in the shared Map, not in individual objects. Without understanding this, you will spend hours suspecting GC or network when the real cause is a single float assignment two call frames away.

Deprecation and migration: how V8 widens a shared field

Here is the subtle part. Suppose 10000 objects share Map M1 where field temp has representation Smi. Now one object assigns obj.temp = 98.6. The field must become Double — but for the whole shape, because the Map is shared. V8 cannot rewrite 10000 objects at that instant (it does not even have a list of them). Instead:

  1. It creates a new Map M2 identical to M1 except temp is Double (a more general representation). The split happens at the right place in the tree, and descendants are re-pointed.
  2. It marks M1 as deprecated — a dead Map that should no longer be used. Existing objects still point at M1 for now.
  3. Migration is lazy. The next time V8 touches one of those objects through a slow path (a property access whose inline cache misses, a %DebugPrint, etc.), it notices the object’s Map is deprecated, walks to the corresponding non-deprecated Map (M2) using the back-pointers from lesson 01, rewrites the object’s layout in place (boxing the integer into a double slot), and updates the object’s map pointer. This is the “MigrationMarker” / migration path.

So a single obj.temp = 98.6 can begin deprecating a shape used by thousands of objects, each paying a small migration cost the next time it is accessed. If that field oscillates between integer and float, you can churn deprecation repeatedly — a real, hard-to-spot deopt source.

Transition mechanics (V8)
Transition lookup key
{property name, attributes}
Re-walking an existing path
0 new Maps — edges followed
Representation widening order
Smi → Double → Tagged (one-way)
Old Map after generalization
marked deprecated
Instance migration
lazy, on next slow-path access
Finding the live Map
walk back-pointers to non-deprecated descendant
Trace transitions
--trace-maps in d8 / node

Why scrambled key orders are pathological

Put the pieces together. A function that builds objects by iterating input keys in arbitrary order (a JSON re-serializer, a generic mapper, generated code) creates a new branch of the transition tree for almost every distinct order it sees. The shapes never repeat, so:

  • Every object allocates fresh Maps and DescriptorArrays (the lesson-01 memory blow-up).
  • The downstream inline cache that reads those objects sees a fresh Map nearly every time → it goes polymorphic, then megamorphic, then gives up (lesson 05).
  • TurboFan cannot specialize on a stable shape, so the hot function never reaches top tier or deopts repeatedly.

The fix is always the same: build from a fixed schema in a constant order (defaulting absent fields to null), or store genuinely dynamic key sets in a Map collection that is built for arbitrary keys.

Quiz

10000 objects share a Map with field `score` represented as `Smi`. One object does `o.score = 1.5`. What happens to the shared Map?

Quiz

Object `a` was built `{}; a.p=1; a.q=2`. Object `b` is then built the same way. How many new Map objects does building `b` allocate?

Order the steps

Order what V8 does when one instance assigns a float to a field that the shared Map records as Smi.

  1. 1 Detect the new value does not fit the field's current representation (Smi)
  2. 2 Create a new Map identical except the field is generalized to Double
  3. 3 Mark the old Map as deprecated so it stops being used for new objects
  4. 4 Leave existing instances pointing at the deprecated Map for now
  5. 5 On each instance's next slow-path access, walk back-pointers to the live Map and migrate it in place
Edge cases

Numbers are not the only generalization. Assigning a heap object where a field previously held only Smis generalizes the representation to Tagged (the most general), which permanently disables some of TurboFan’s unboxing optimizations for that field. A field that holds null for “absent” and an object for “present” is already Tagged from the first non-Smi write — which is usually fine, but be aware it forecloses the unboxed-integer fast path. If a field is hot and numeric, keep it numeric.

Recall before you leave
  1. 01
    Name every kind of event that creates a transition to a new Map, beyond adding a property.
  2. 02
    Explain deprecation and lazy migration step by step.
  3. 03
    Why does building objects by iterating input keys in arbitrary order destroy performance, in terms of the transition tree?
Recap

A transition is an edge in a shape tree shared across the isolate. Adding a property looks up a {name, attributes} edge in the current Map’s TransitionArray and follows it (free) or creates it (one new child Map extending the parent’s DescriptorArray). But three other events also transition: an array’s elements kind widening, a field’s representation generalizing (Smi → Double → Tagged), and a field losing constness. Representation generalization is special because the representation belongs to the shared Map: V8 creates a generalized Map, deprecates the old one, and migrates each instance lazily — on its next slow-path access it follows back-pointers to the live Map and rewrites its storage in place. Building objects in scrambled key orders branches the tree endlessly, blowing up Map memory, driving the downstream inline cache megamorphic, and starving TurboFan of a stable shape; the fix is a fixed-schema construction order or a Map collection for truly dynamic keys. Trace it all with --trace-maps. Now when you see a sudden throughput drop after an innocuous-looking assignment to an existing field, check --trace-maps for deprecation events — you may have just widened a representation shared by thousands of objects.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 8 done
Connected lessons
appears again in184

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.