Frontend Development

V8: How JavaScript Runs

20 min
The AI Space Team

How does JavaScript actually run in V8? This post walks through the real pipeline—source → AST → bytecode → tiered machine code—then explains stack vs heap, closures, V8 garbage collection, and the hands-on Node/d8 tricks to trace optimizations and find leaks.

V8: How JavaScript Runs

When people say “JavaScript is interpreted,” they’re usually describing the developer experience (you can run code without a separate build step). Under the hood, modern engines—especially V8 (Chrome, Node.js, etc.)—do a lot more: parsing, bytecode generation, tiered JIT compilation, and sophisticated garbage collection.

This post is a practical, modern mental model of V8 (used by Chrome and Node.js), covering:

  • JavaScript types and the classic typeof null oddity

  • Memory spaces (call stack vs heap) and how values are stored

  • Closures and why they can retain memory

  • Garbage collection (young/old generations, stop-the-world, concurrent marking)

  • V8’s execution pipeline: Ignition bytecode → tiered JIT compilers (Sparkplug, Maglev, TurboFan)

  • What “JIT” really includes in JavaScript

  • How to debug performance and memory leaks in real projects

  • A short Interview Q&A appendix


JavaScript types: dynamic + “weakly typed”

A statically typed language checks types ahead of time (compile time). That doesn’t necessarily mean you must write type annotations—many static languages support type inference.

JavaScript is dynamically typed: values carry types at runtime, and operations are validated while the program runs.

JavaScript is also often described as weakly typed because it performs implicit coercions (e.g., "1" + 2 === "12"). TypeScript adds static checks on top of JS, but the runtime is still JS.

JavaScript has 8 types:

  • Primitives(7) Undefined, Null, Boolean, Number, BigInt, String, Symbol

  • Reference type (1): Object (functions are objects too)

Why typeof null is "object":

This is a long-standing historical behavior that can't be changed without breaking the web:

typeof null; // "object"
null === null; // true (use this to check null)

So: don’t use typeof for null; use strict equality.


Memory model: stack vs heap

In spec terms, JavaScript doesn’t promise a physical “stack” or “heap.” But in real engines, the model is still useful:

  • Call stack (stack): “where am I?” The call stack is the chain of active function calls and their execution state (roughly: arguments, local bindings, return address). When a function returns, its stack frame can be reclaimed quickly.

  • Heap: “what objects exist?” The heap stores dynamically allocated things that can outlive a single function call: objects, arrays, functions, closures, internal runtime structures, etc.

  • Code space / executable memory (engine-internal) Where compiled machine code can live.

Primitive vs reference assignment:

let a = 10;
let b = a;
b = 20;
console.log(a); // 10 (copied the value)

let obj1 = { x: 1 };
let obj2 = obj1;
obj2.x = 99;
console.log(obj1.x); // 99 (copied a reference)

Primitives copy values. Objects copy references (pointers).


Closures: “why does memory stay alive?”

A closure is a function plus the lexical environment it needs in order to run.

If an inner function references variables from an outer scope, the engine must keep those variables alive as long as the inner function might be used.

How V8 implements closures

V8 uses internal structures (often discussed as contexts) to store captured variables so they outlive the outer function call. Those context objects are typically heap-allocated if they “escape” the stack.

function makeCounter() {
  let count = 0;
  return function inc() {
    count++;
    return count;
  };
}

const c = makeCounter();
c(); // 1
c(); // 2

Even though makeCounter()has finished, countis still reachable through inc(), so it can’t be freed.

Important nuance: engines can optimize some cases (escape analysis), so not every closure automatically implies heavy heap usage—but the safe mental model is: captured variables can keep memory alive.


Garbage Collection in V8

JavaScript uses automatic memory management: it allocates memory for values and reclaims memory for values that are no longer reachable.

The Generational Hypothesis

V8 is built around this idea:

  • Most objects die young

  • Objects that survive tend to live longer

So V8 separates the heap into generations and collects them differently.


GC Roots & Reachability: what GC really means by “still in use”

Most beginners think GC works like: “objects older than X get deleted.”

In reality, modern JS engines primarily use reachability:

If an object is still reachable from a set of roots, it’s considered “live” and cannot be collected.

What are “GC roots”?

From a web developer perspective, roots commonly include:

  • Global objects like window (and per-iframe globals)

  • Anything referenced by the current call stack (locals in currently running functions)

  • Objects referenced by the browser’s native side (DOM / event system), i.e. native → JS references

A tiny example: reachable vs unreachable

let user = { name: "Joe" };  // reachable from global => live

user = null;                 // original object might become unreachable => collectible

GC doesn’t care that the object “exists” — it cares whether something can still reach it through references.

Why this matters for debugging leaks

When DevTools shows an object “stuck” in memory, the real question is:

“What chain of references keeps it reachable from a root?”

That chain is what DevTools calls retainers (the “who is holding it” tree).


Young generation: Minor GC (Scavenger)

In V8, minor GC focuses on newly allocated objects and commonly uses a copying / scavenging approach (a “semi-space” style collector). The engine copies live objects and discards the rest, which also reduces fragmentation.

If an object survives enough minor collections, it may be promoted to the old generation.

Why the young generation exists

Most objects in JS die quickly:

function foo() {
  const temp = { a: 1 }; // often becomes unreachable very soon
  return 42;
}

To collect these cheaply, V8 keeps newly-created objects in the Young Generation (also called New Space).

Scavenger in one sentence

Scavenger is a copying GC: it copies live objects out of the young space, and everything not copied is garbage.

The “two-space” picture (semi-space)

Young gen is split into two semi-spaces:

  • From-space: where new objects are allocated

  • To-space: empty space used during GC

When From-space gets full-ish, a Minor GC happens:

  1. Start from roots (stack, registers, globals, etc.), find references pointing into young gen

  2. Copy live objects from From-space → To-space

  3. Update references to the new addresses

  4. Swap roles: To-space becomes the new From-space

  5. Anything left behind in the old From-space is “discarded” (collected)

Why copying is fast

Because we don’t “free” each dead object. We just copy what’s alive, then flip spaces. Dead objects are abandoned with almost no work.

Promotion: moving survivors to old generation

If an object survives multiple minor GCs (or becomes “old enough”), V8 may promote it into the Old Generation, where a different GC strategy is used.

Why you should care (practical)

  • High allocation rate → more minor GCs

  • Minor GCs are usually quick, but a lot of allocations can still create overhead or jank (especially on low-end devices)


Old generation: Major GC (Mark-Sweep & Mark-Compact)

For long-lived objects, V8 uses a marking phase (find what’s reachable from roots), plus sweeping/compaction work to reclaim and defragment memory.

Why old gen needs a different algorithm

Old gen objects tend to be:

  • larger

  • long-lived

  • more expensive to move around frequently

So the old generation uses marking-based algorithms.

Mark-Sweep vs Mark-Compact (what’s the difference?)

Mark-Sweep (concept):

  • Mark: start from roots, walk references, mark reachable objects as “alive”

  • Sweep: reclaim memory for unmarked (dead) objects

Problem: after sweeping, you may get fragmentation (free memory becomes many small gaps).

Mark-Compact (fix fragmentation):

Mark-Compact starts with the same Mark phase, but changes the cleanup phase:

  1. Mark live objects (same as mark-sweep)

  2. Compact: move all live objects together (toward one side)

  3. Update pointers (references to moved objects)

  4. Freed memory becomes one large contiguous region

Why compaction matters

Fragmentation can cause:

  • higher memory usage

  • allocation failures for large blocks

  • slower allocations

Compaction reduces fragmentation, but it’s more work because moving objects requires updating references.

Why you should care (practical)

  • Big long-lived object graphs (caches, stores, global maps) increase major GC cost

  • “Memory leak” in JS often means you unintentionally keep references → objects get stuck in old gen → major GC gets heavier


Stop-the-world and how V8 reduces pauses

GC work often requires pauses, because JavaScript runs on the main thread (in the browser) and object graphs must be consistent. These pauses are called stop-the-world events.

Modern V8 has reduced pause impact a lot via:

  • incremental work (break big steps into smaller slices)

  • parallel work (use multiple threads)

  • concurrent work (do parts while JS continues)


V8 heap isn’t just “new/old”

Besides young/old, V8 also has other logical spaces (e.g., executable code space, read-only heap/roots, etc.). For example, V8 stores some core immutable roots in a read-only heap and uses “static roots” techniques to speed up access to fundamental objects.


How V8 runs your code: source → AST → bytecode → tiered machine code

Step 1: Parse into AST

V8 parses source text into an AST (Abstract Syntax Tree). This is the foundation for execution and optimization.

What is an AST?

An AST is a tree-shaped representation of code structure.

Example code:

const x = 1 + 2;

A simplified AST shape:

  • Program

    • VariableDeclaration (const)

      • VariableDeclarator (x)

        • init: BinaryExpression (+)

          • left: NumericLiteral (1)

          • right: NumericLiteral (2)

Tokenize (Lexing) → Parse

Tokenizing turns source code into tokens — the smallest meaningful pieces.

For const x = 1 + 2;, tokens are roughly:

  • const (keyword)

  • x (identifier)

  • = (operator)

  • 1 (number)

  • + (operator)

  • 2 (number)

  • ; (punctuation)

Parsing takes tokens and applies grammar rules to build the AST:

Source text → tokens → AST

Generate an AST yourself (quick demo with acorn)

npm i acorn// ast-demo.js

import * as acorn from "acorn";

const code = `const x = 1 + 2;`;
const ast = acorn.parse(code, { ecmaVersion: "latest", sourceType: "module" });

console.log(JSON.stringify(ast, null, 2));

Run:

node ast-demo.js

Why Babel and ESLint work on AST

Babel: transform code by transforming AST

Babel’s workflow is basically:

  • Parse source → AST

  • Transform AST (rewrite nodes)

  • Generate code from the new AST

So “ES6 → ES5” is really: ES6 AST → ES5 AST → ES5 code.

ESLint: find problems by traversing AST

ESLint does:

  • Parse source → AST

  • Walk the AST

  • Run rules against patterns (e.g., “no unused vars”, “prefer const”)

That’s why lint rules can be precise: they’re checking structure, not regex on text.


Step 2: Ignition generates and executes bytecode

V8’s interpreter tier is Ignition.

Ignition compiles functions to a compact bytecode and executes that bytecode. One motivation: bytecode can be much smaller than baseline machine code, improving memory usage and startup characteristics.


Step 3: Warm-up feedback (Inline Caches + feedback vectors)

While executing, V8 collects runtime feedback about operations like property access and function calls.

  • Inline caches (ICs): help V8 remember what usually happens at a call site / property access.

  • Feedback vectors: store the observed feedback so compilers can optimize based on “what’s usually true”.

Object Shapes + Inline Caches: why “consistent objects” run faster

This is one of the most useful “engine-aware” concepts that actually helps day-to-day coding.

The idea: V8 wants stable object layouts

V8 uses Hidden Classes (also called Maps) to represent the “shape” (layout) of an object: what properties it has, and where they live.

Then V8 uses Inline Caches (ICs) to speed up repeated operations like obj.x by remembering how property access worked last time.

Monomorphic vs polymorphic (simple meaning)

  • Monomorphic IC: “I always see the same shape here” → fastest

  • Polymorphic IC: “I see a few shapes” → still ok, but more checks

  • Megamorphic IC: “too many shapes” → V8 often falls back to slower paths

“Bad” example: creating objects with different shapes

function makeUserA() { return { id: 1, name: "A" }; }
function makeUserB() {
  const u = { id: 2 };
  u.name = "B";            // property added later (shape change)
  return u;
}

Even though both end up with {id, name}, they may go through different shape transitions. In hot code, that can hurt IC stability.

“Good” example: initialize consistently

function makeUser(id, name) {
  return { id, name };     // same fields, same order
}


Step 4: Tiered JIT compilation

Modern V8 doesn’t have “one JIT.” It has tiers:

  • Sparkplug: baseline (non-optimizing) compiler that sits between Ignition and optimizing tiers. It compiles quickly and improves performance without heavy optimization cost.

  • Maglev: fast optimizing JIT, positioned between Sparkplug and TurboFan to generate “good enough” optimized code quickly.

  • TurboFan: top-tier optimizing compiler for the hottest code paths (higher compile cost, best peak performance).


What "JIT" Means in JavaScript (in V8)

When people say "JavaScript uses JIT," they usually don't mean "there is one JIT compiler."

In modern V8, JIT is a whole runtime strategy: run the code quickly at first, collect feedback while it runs, then selectively compile the hottest parts into faster machine code.

In other words, JIT in V8 includes:

  • Ignition bytecode execution (fast startup)

  • Profiling + feedback collection (ICs, feedback vectors)

  • Tiered compilation (Sparkplug → Maglev → TurboFan)

  • OSR + deopt (switching tiers mid-flight / falling back safely)

  • Code caching (reusing generated code across loads when possible)


Fast start: bytecode + interpreter (Ignition)

V8 first parses your source, then Ignition produces bytecode and starts executing it immediately. While running, Ignition also collects runtime feedback (e.g., what "shape" of objects you pass to property accesses). This feedback is stored and reused to speed things up later.

Important nuance: Ignition is not "the JIT compiler" by itself — it's the interpreter tier that enables fast startup and gathers the data JIT compilers need.


Runtime profiling: "hot code" detection

As bytecode executes, V8 tracks which parts run a lot (typically hot functions and hot loops). "Hot" simply means: compiling this will likely pay off because it will run many more times.

So "hot code" is best understood as hot bytecode execution, not the raw source text.


Tiered compilation: multiple compilers, all producing machine code

Once something is hot enough, V8 can tier it up through multiple compilers:

  • Sparkplug (baseline compiler): compiles bytecode → machine code very fast, but doesn’t do heavy optimizations.

  • Maglev (fast optimizing JIT): compiles using bytecode + collected feedback to generate “good enough” optimized machine code, faster to compile than TurboFan.

  • TurboFan (top-tier optimizing compiler): does deeper optimizations for peak performance, but costs more CPU time to compile.

Each tier can produce machine code, but they trade off compile time vs runtime speed.


“Choosing a compiler” is policy-driven (cost vs benefit)

V8 doesn’t jump to TurboFan immediately because compiling is work. The tiering policy aims to:

  • use Sparkplug quickly for cheap wins,

  • use Maglev when code stays hot and feedback is stable,

  • use TurboFan only for the hottest, most profitable code paths.


OSR (On-Stack Replacement) and deopt: switching tiers while running

A powerful part of tiering is that V8 can switch execution modes:

OSR (On-Stack Replacement): swap in compiled/optimized code while a function is still running (often inside a hot loop). Sparkplug's design explicitly calls out OSR as a key capability.

Example:

function sum(arr) {
  let s = 0;
  for (let i = 0; i < arr.length; i++) { // hot loop
    s += arr[i];
  }
  return s;
}

  • Start: interpreted bytecode

  • Loop runs a lot → OSR kicks in → V8 swaps to compiled code mid-loop

  • If it stays hot and stable → it may tier up further

Deoptimization (deopt): if assumptions used by optimized code become invalid, V8 can fall back to a lower tier and continue correctly. Sparkplug's discussion highlights tiering down on deopt as a normal path.


One-sentence takeaway

JIT in V8 isn’t just “a compiler.” It’s the combination of Ignition bytecode execution + profiling/feedback + tiered compilers (Sparkplug/Maglev/TurboFan) + mechanisms like OSR and deopt that continuously adapt performance while your program runs.


Practical performance advice (engine-aware, not engine-obsessed)

You don't need to "write for V8," but a few habits align well with how tiered JITs work:

  • Avoid long tasks

    that block the main thread (user-perceived jank is often long JS tasks + layout/paint work, not just raw JS speed).

  • Keep object shapes stable

    in hot code (e.g., initialize object fields consistently) so inline caches remain effective. (This ties to how IC feedback is used for optimized code generation.)

  • Watch allocation rate: excessive short-lived allocations increase minor GC frequency; huge retained graphs increase major GC cost.

  • Keep bundles smaller: less parsing/compiling/memory overhead up front.


“Running JavaScript in V8” for real: Node, d8, and useful flags

Node.js (V8 embedded)

Node embeds V8, so V8 heap limits affect Node apps.

Node’s memory tuning guide explains --max-old-space-size, which controls the old-space limit (where long-lived objects live):

node --max-old-space-size=2048 app.js

d8 (V8’s own shell)

d8 is V8’s own developer shell—useful for running JS in a “closer-to-engine” environment and exploring flags.

d8 --help
d8 your_script.js

Observing optimization and deopt (learning/debugging)

V8 has flags like --trace_optand --trace_deoptfor tracing optimization and deoptimization behavior (availability depends on your runtime/build).


How to detect memory leaks (and prevent them)

A memory leak in GC languages usually means: you’re unintentionally keeping references alive, so the GC can’t reclaim memory.

In the browser: Chrome DevTools workflow

Use the Memory panel to:

  • Take heap snapshots

  • Compare snapshots over time

  • Inspect Retainers (what is keeping an object alive?)

  • Look for common suspects like detached DOM nodes

A practical approach:

  1. Reproduce the behavior (navigate, open/close UI, repeat)

  2. Take snapshot A (baseline)

  3. Repeat the action 5–10 times

  4. Take snapshot B

  5. Compare: what keeps growing? what’s retaining it?

Common leak patterns in real apps

  • Event listeners not removed

  • Timers/intervals not cleared

  • Global caches that only grow

  • Detached DOM nodes retained by JS references

  • Long-lived closures capturing large objects unintentionally

Example pitfall:

const cache = [];
function handleHuge(data) {
  // data is large, and we accidentally keep it forever
  cache.push(() => data);
}

In Node.js: inspect heap + tune limits

Node provides:

  • --inspect (connect DevTools)

  • Heap snapshots you can load into Chrome DevTools

  • Memory flags like --max-old-space-size


Appendix: Interview Q&A (Quick Hits)

  1. Explain stack vs heap in JavaScript. The call stack holds execution state (function call frames, “where the code is running”). The heap holds dynamically allocated objects (arrays, objects, functions/closures) that can outlive a single call. Variables on the stack can store references pointing to heap objects.

  2. What’s a closure, and why can it cause memory issues? A closure is a function plus the outer-scope variables it captures. If a closure is kept alive (e.g., stored in a global, a long-lived event handler, a cache), it can keep its captured variables alive too—sometimes unintentionally retaining large objects and causing memory growth.

  3. What is the generational hypothesis, and how does V8 use it? Most objects die young, and objects that survive tend to live longer. V8 splits the heap into young and old generations, collecting the young gen frequently (cheaply) and the old gen less often (more expensive).

  4. Minor GC vs Major GC?

    • Minor GC (young gen): frequent, optimized for short-lived objects, uses copying/scavenging.

    • Major GC (old gen): less frequent, marking + sweeping/compacting; more expensive and more likely to impact pauses.

  5. What does “stop-the-world” mean? Is V8 still stop-the-world? Stop-the-world means JavaScript pauses while GC work happens. V8 still has pauses, but many phases are incremental, parallel, and concurrent to reduce pause time.

  6. Why does V8 get faster after running for a while? Warm-up: V8 starts with bytecode execution and collects runtime feedback, then compiles hot code to faster machine code using tiered compilers. Performance can also drop due to deopts, changing shapes/types, or GC pressure.

  7. What causes deoptimization (deopt)? Optimized code is built on assumptions (object shapes, value types, stable call targets). If those assumptions break—e.g., you pass very different shapes/types into a hot function—V8 may deopt back to a lower tier to stay correct.

  8. How do you detect a memory leak in the browser? Use Chrome DevTools Memory panel. Reproduce the scenario repeatedly, take and compare heap snapshots, and inspect retainers. Common culprits: detached DOM nodes, unremoved listeners, growing caches, timers.

  9. How do you detect a memory leak in Node.js? Watch memory over time (RSS / heap used), then use --inspect+ heap snapshots. If heap grows steadily and never drops after GC cycles, you likely have unintended retention.

  10. Practical tips to avoid leaks?

    • Clear intervals/timeouts and unsubscribe listeners on teardown

    • Avoid unbounded caches (use LRU/TTL)

    • Be careful with closures capturing large objects

    • Ensure DOM references don’t outlive the DOM (cleanup on unmount)

    • Use WeakMap/WeakSet where appropriate for metadata caches


Reference

V8 (official)

Chrome DevTools (official)

Node.js (official)

MDN (official)