Skip to content
← Journal
12 min readAta Mohammadi

Senior Frontend Interview Prep, Part 1: Inside the JavaScript Engine

Fifteen interview questions on how V8 turns source text into machine code — tiers, hidden classes, inline caches, elements kinds and deoptimisation — with the answers verified against a live engine.

At the senior and staff level, "do you know JavaScript" stops meaning "can you use map" and starts meaning "do you know what the machine does with your code". This is the first of seven deep-dives into the questions that actually get asked, and it starts at the bottom: the engine.

Everything below has been checked against V8 as it ships in Node 26. Where a claim is observable, I ran it — output included — because a surprising amount of received wisdom about V8 is two or three versions stale, and reciting stale wisdom confidently is a worse outcome than saying "I'm not sure".

Virtual machine execution, JIT compilation and engine internals

1. How does V8 get from source text to native machine instructions?

V8 runs a tiered pipeline, trading compile time against code quality at each step.

A streaming scanner and parser turn source bytes into an AST. Ignition, a register-based (accumulator) interpreter, lowers that AST to bytecode and starts executing immediately — no waiting for a compiler. While it runs, each interesting bytecode site records what it actually saw into a feedback vector.

If a function stays warm, Sparkplug compiles its bytecode to machine code in a single template-driven pass — no IR, no optimisation, just removing interpreter dispatch overhead. Warmer still, and Maglev builds an SSA graph and applies cheap optimisations. Genuinely hot, and TurboFan takes over: it builds a Sea-of-Nodes graph, reads the accumulated feedback, and speculates — unboxing numbers, inlining callees, eliminating bounds checks — emitting tight architecture-specific code.

If a speculation later proves wrong, execution deoptimises back down the stack.

The one-sentence version worth memorising: four tiers, because the gap between "starts instantly" and "runs fast" is too wide to cross in one jump.

2. What is a hidden class, and what problem does it solve?

In a dynamically typed language, obj.x cannot be resolved to a fixed memory offset at compile time. V8 solves this by giving every object a hidden class — a Map in V8's source, a Shape in SpiderMonkey — describing its layout. Objects built the same way share one, and a property read becomes "check the map pointer, then load from a known offset" instead of a hash probe.

Adding a property walks a transition tree: from the current map to the map that also describes the new property, creating it if nobody has needed it before.

// Map0 (empty) → Map1 (x at offset 0) → Map2 (x at 0, y at 1)
const point = {};
point.x = 10;
point.y = 20;

Here is the part that catches people out. Run this under node --allow-natives-syntax:

function make(a, b) {
  const o = {};
  o.x = a;
  o.y = b;
  return o;
}

const p1 = make(1, 2);
const p2 = make(3, 4);
const p3 = { x: 1 };
p3.y = 2;
const p4 = { y: 2, x: 1 };

console.log("same construction:", %HaveSameMap(p1, p2));
console.log("literal then add :", %HaveSameMap(p1, p3));
console.log("keys swapped     :", %HaveSameMap(p1, p4));
same construction: true
literal then add : false
keys swapped     : false

Two objects with identical keys, identical values and identical key order do not share a map, because one started life as {} and the other as { x: 1 }. An object literal gets an initial map sized for the properties in the literal; building up from {} walks a different path through the transition tree. If you want shape stability across a hot path, construct objects the same way, not merely with the same keys.

3. Monomorphic, polymorphic, megamorphic — what changes?

An inline cache caches the resolution of a property access at the call site itself. Its state is a function of how many distinct maps that one site has observed:

State Maps seen What executes Cost
Monomorphic 1 Compare one map pointer, load from a constant offset Best case; a couple of cycles, usually an L1 hit
Polymorphic 2–4 An inlined chain of map comparisons Still fast; some branch-prediction pressure
Megamorphic 5+ Site gives up caching, falls back to the global stub cache or a hash probe Pathological: cache misses, no inlining

The number to remember is four. The insight to state is that the state belongs to the call site, not to the object — which is why a generic utility called with five different shapes poisons itself for every caller, and why the fix is often to give the hot caller its own specialised copy rather than to change the data.

4. What triggers an eager deoptimisation bailout, and what does it cost?

TurboFan emits guards encoding its assumptions: this value is a Smi, this object has this map, this array has this elements kind. When a guard fails, the optimised frame cannot simply continue.

V8 must reconstruct the equivalent interpreter frame — restoring locals from registers and stack slots using bailout metadata recorded at compile time — update the feedback vector to record the wider type, and resume in Ignition. That reconstruction is the cost, and it is why deoptimisation is more expensive than "just running slower".

The pathological case is a deopt loop: the function reoptimises on the new feedback, hits a third shape, deoptimises again. V8 eventually marks the function as not worth optimising and leaves it in the interpreter permanently. A 60 fps animation callback that does this will visibly stutter, and no amount of profiling the call graph will show you why — you need --trace-deopt.

5. Smi versus HeapNumber

V8 avoids allocating for integers that fit in a tagged pointer word. On 64-bit builds with pointer compression, a Smi carries a 31-bit signed payload in the upper bits of a 32-bit tagged word, with the low bit used as the tag: 0 means "this word is the number, shift it", 1 means "this word is a pointer, dereference it".

Anything else — a fraction, a value beyond Smi range, a NaN — becomes a HeapNumber: a heap allocation holding a boxed double, with the indirection and the GC pressure that implies.

The practical consequence is the one people miss: a numeric array that holds only integers stores them unboxed inline. Push one 0.5 into it and the entire backing store is converted to doubles. Push one null and it becomes an array of pointers. Both conversions are O(n) copies, and neither is reversible.

6. Why is delete obj.prop bad in hot code?

Adding a property walks forward through the transition tree. Deleting one usually cannot walk backwards — the map that describes "x and y, but y was removed" is not generally reachable — so V8 gives up on shapes for that object and switches it to dictionary mode: a private hash table, no shared map, no offset arithmetic.

function make(a, b) {
  const o = {};
  o.x = a;
  o.y = b;
  return o;
}

const o = make(1, 2);
delete o.x;
console.log("dictionary mode:", !%HasFastProperties(o));
dictionary mode: true

Every inline cache that touches that object goes megamorphic, since it now sees an object with no shared map at all. Set the property to null or undefined if you mean "no value". Use a Map if you mean "a collection of keys that come and go" — that is what Map is for, and it never had shapes to lose.

7. What is Sea-of-Nodes and why use it?

A classical optimiser holds a control-flow graph of basic blocks, each containing an ordered list of instructions. TurboFan instead uses Sea-of-Nodes, where control and data live in one graph and the only ordering is the dependencies you can actually prove: value edges, effect edges (for operations with side effects) and control edges.

Because most nodes are not pinned to a block, the scheduler is free to place them late — which makes loop-invariant code motion, common-subexpression elimination and dead-code elimination fall out of the representation rather than needing separate passes over a fixed block structure.

Worth knowing as a footnote: Sea-of-Nodes is powerful but hard to debug and hard to schedule well, which is exactly why Maglev, added later for the middle tier, uses a conventional CFG instead. "The newer compiler deliberately chose the older representation" is a good answer to "what are the downsides?"

8. When will a JIT inline a function?

Inlining pastes the callee's body into the caller, removing the frame setup, the argument marshalling and the indirect jump — and, more importantly, exposing the callee's internals to the caller's optimiser.

TurboFan's heuristics, roughly:

  • The call site must be hot.
  • The callee's bytecode must be small — a few hundred bytes, with a larger allowance for very hot sites.
  • The call site should be monomorphic. A polymorphic site needs a dispatch guard per target, which eats the benefit.
  • Constructs that complicate the frame — try/catch, generators, eval, arguments leakage — reduce eligibility.

The interview-grade observation: inlining is the optimisation that enables the others. A tiny getter that does not get inlined is not just a function call, it is an opaque wall the optimiser cannot see through.

9. Elements kinds, and why the transition is one-way

V8 tracks what an array's backing store holds, so that indexing can compile to raw memory arithmetic:

  • PACKED_SMI_ELEMENTS — small integers, unboxed, no holes
  • PACKED_DOUBLE_ELEMENTS — doubles, unboxed
  • PACKED_ELEMENTS — arbitrary tagged values
  • HOLEY_* — the same three, but with gaps

Transitions go from specific to general only. Here it is, observed:

const kind = (a) =>
  (%HasSmiElements(a) ? "SMI" :
   %HasDoubleElements(a) ? "DOUBLE" :
   %HasObjectElements(a) ? "OBJECT" : "?") +
  (%HasHoleyElements(a) ? " (holey)" : " (packed)");

const a = [1, 2, 3];
console.log("start         :", kind(a));
a.push(4.5);
console.log("after push 4.5:", kind(a));
a.pop();
console.log("after pop     :", kind(a));

const b = [1, 2, 3];
b[5] = 6;
console.log("after hole    :", kind(b));
b.length = 3;
console.log("hole removed  :", kind(b));
start         : SMI (packed)
after push 4.5: DOUBLE (packed)
after pop     : DOUBLE (packed)
after hole    : SMI (holey)
hole removed  : SMI (holey)

Removing the float does not restore SMI. Truncating past the hole does not restore packed. The array carries the damage for its whole life, and the cost of HOLEY_* is real: a read from a possible hole has to consult the prototype chain, because somebody might have put an index on Array.prototype.

Which is why new Array(1000) is a trap — it creates a thousand holes — and Array.from({ length: 1000 }, () => 0) is not.

10. What is the feedback vector?

A side array allocated next to a function's bytecode, with one slot per bytecode site that can benefit from specialisation: property loads and stores, binary operators, calls, instanceof.

Ignition writes into it as it runs — the maps it saw, whether operands were Smis or doubles, which function a call site actually reached. Sparkplug and Maglev read and keep writing it. TurboFan consumes it as the basis for speculation.

The framing that lands in an interview: the feedback vector is the profile, and optimisation is a bet placed on it. The guards in the generated code are the terms of the bet; deoptimisation is losing it.

11. Eager versus lazy (pre-)parsing

Compiling an entire bundle up front wastes time on code that may never run. So V8 parses twice:

  • Eager parsing for code that must run now: full AST, scope resolution, bytecode.
  • Pre-parsing for function bodies that are merely declared: check the syntax, note which outer variables are referenced so closures can be allocated correctly, and stop. No AST, no bytecode. It is roughly twice as fast as a full parse.

When a pre-parsed function is finally called, it is parsed again — so a function that is pre-parsed and then immediately invoked costs more than one parsed eagerly. That is what the (function(){ … })() wrapping convention exploits: V8 treats a function expression in parentheses as a likely IIFE and parses it eagerly the first time. Bundlers emit that shape deliberately; it is not a stylistic tic.

12. What does code caching actually cache?

On a first visit, V8 compiles the script and can serialise the result — bytecode, metadata, the string table — into the browser's disk cache alongside the script's HTTP cache entry. On a later visit the deserialised bytecode is handed straight to Ignition, skipping scan, parse and bytecode generation entirely.

Two details that show experience. It is bytecode caching, not machine-code caching: optimised code is never cached, so the first-run warm-up still happens. And the cache key includes the script's URL and content, so a hashed filename that changes every deploy throws the cache away — the reason large sites split rarely-changing vendor chunks from frequently-changing app chunks is partly this, not just HTTP caching.

13. How do V8, JavaScriptCore and SpiderMonkey compare?

Engine Tiers
V8 (Chrome, Node, Edge) Ignition (interpreter) → Sparkplug (baseline JIT) → Maglev (mid-tier SSA JIT) → TurboFan (optimising JIT)
JavaScriptCore (Safari) LLInt (low-level interpreter) → Baseline JIT → DFG (data-flow-graph JIT) → FTL (optimising JIT on the B3 backend)
SpiderMonkey (Firefox) Interpreter → Baseline Interpreter → Baseline JIT → WarpMonkey/Ion (optimising JIT)

They converged on the same answer independently: several tiers, each cheaper to enter and more expensive to run than the last, with profiling data flowing upward. The differences that matter in practice are on-stack replacement behaviour and how aggressively each tier speculates — which is why a micro-benchmark that looks great in Chrome can look ordinary in Safari.

14. What is an IC handler stub?

An inline cache starts uninitialised. On first execution, V8 drops into C++ runtime code, resolves the property on the object's map, and then produces (or reuses) a small machine-code routine — a handler stub — that encodes "if the map is this, load from this offset".

The call site is then patched in memory to jump straight at that stub. Subsequent executions never re-enter the runtime: compare, load, done. Polymorphic sites keep a small list of stubs; megamorphic sites abandon the list for a shared global stub cache keyed by map and property name.

This is why "self-modifying code" is not a metaphor in a JIT. It is literally what is happening.

15. Why does for…in behave differently from Object.keys()?

for…in enumerates enumerable string keys including inherited ones. When the object and its whole prototype chain are in fast mode, V8 can build an EnumCache on the map — a precomputed key array — and the loop becomes an array walk.

Invalidate any of that (dictionary mode anywhere on the chain, shadowed properties, a delete mid-loop) and the cache is gone; the engine walks the chain, probes hash tables, and re-checks enumerability and shadowing on every step.

Object.keys() only ever considers own enumerable string keys, so it can read the descriptor array off the shape directly. It is more predictable, and it is almost always what you meant anyway.

Practising this properly

Reading about deoptimisation is not the same as seeing one. Three flags turn all of the above from trivia into something you can observe:

# Which functions optimise, and when
node --trace-opt app.js

# Which deoptimise, why, and at which bytecode offset
node --trace-deopt app.js

# Inspect maps, elements kinds and property storage directly
node --allow-natives-syntax app.js

Spend an hour making a function deoptimise on purpose. Watch an array fall from PACKED_SMI to HOLEY_ELEMENTS. Then the answers above stop being recited and start being remembered, which is a difference an interviewer can hear from across the table.


Next in the series: the heap — generational collection, the scavenger, concurrent marking, and the four leak topologies that account for nearly every "the tab is using 2 GB" bug report.

Next step

Tell us what is broken or what should exist.

Send the shape of the problem and any constraints you already know — budget, deadline, the stack you are stuck with. You will get a written reply from the engineer who would do the work, not a sales sequence.