JavaScript November 20, 2024 Aditya Rawas

From Code to Execution: How the JavaScript Engine Works

JavaScript is a fascinating language, beloved for its versatility in building modern web applications. But have you ever wondered what happens when you write JavaScript code? How does it go from plain text in your editor to something the machine understands and executes?

This deep dive traces the full journey — from source characters to optimized machine code — and explains what this means for writing fast JavaScript. The same V8 engine covered here also powers Node.js — making concepts like the event loop and thread model directly relevant.


The JavaScript Engine Landscape

Before diving into mechanics, it helps to know which engines power the JavaScript world:

EngineUsed InJIT Compiler
V8Chrome, Node.js, Deno, EdgeIgnition + Sparkplug + TurboFan
SpiderMonkeyFirefoxWarpMonkey + Ion
JavaScriptCoreSafari, BunLLInt + Baseline JIT + DFG + FTL
HermesReact NativeBytecode-first (AOT)

This guide focuses on V8 since it powers both the dominant browser (Chrome) and the dominant server runtime (Node.js). But the concepts — parsing, bytecode, JIT compilation — apply to all modern engines.


Stage 1: Parsing the Source Code

The journey begins when V8 receives your JavaScript as a string of characters. The first job is to make sense of that string.

Tokenization (Lexical Analysis)

The lexer scans the source left to right and breaks it into tokens — the smallest meaningful units of the language.

function add(a, b) {
    return a + b;
}

This becomes a stream of tokens:

FUNCTION | IDENTIFIER(add) | LPAREN | IDENTIFIER(a) | COMMA |
IDENTIFIER(b) | RPAREN | LBRACE | RETURN | IDENTIFIER(a) |
PLUS | IDENTIFIER(b) | SEMICOLON | RBRACE

Abstract Syntax Tree (AST)

The parser takes that token stream and builds an Abstract Syntax Tree (AST) — a structured, hierarchical representation of your code’s meaning.

FunctionDeclaration
 ├── id: Identifier (add)
 ├── params: [Identifier(a), Identifier(b)]
 └── body: BlockStatement
      └── ReturnStatement
           └── BinaryExpression (+)
                ├── left: Identifier (a)
                └── right: Identifier (b)

The AST strips away syntactic noise (whitespace, semicolons, parentheses) and retains only what matters for execution. Tools like ESLint, Prettier, and TypeScript all operate on ASTs.

Eager vs Lazy Parsing

V8 doesn’t fully parse everything upfront. It uses lazy parsing — a lightweight pre-parse pass that skips function bodies until they’re actually called. This makes initial load faster by deferring work on code that may never run.

Functions declared at the top level that are called immediately get eager parsing. Everything else is pre-parsed first.


Stage 2: Ignition — The Bytecode Interpreter

Once the AST is ready, V8’s Ignition interpreter compiles it into bytecode — a compact, platform-independent instruction set that sits between source code and machine code.

For our add function, Ignition generates bytecode roughly like:

LdaNamedProperty a0, [0]   // Load argument 'a'
Star r0                     // Store in register r0
LdaNamedProperty a0, [1]   // Load argument 'b'
Add r0                      // Add r0 + accumulator
Return                      // Return the result

Why Bytecode Instead of Direct Machine Code?

  • Startup speed: Generating bytecode is much faster than compiling directly to machine code. The interpreter starts running almost immediately.
  • Portability: The same bytecode runs on any CPU architecture — V8 handles the translation.
  • Profiling: As Ignition runs bytecode, it collects type feedback — information about what types of values functions actually receive. This is critical for the next stage.

Stage 3: Sparkplug — The Baseline Compiler

V8 added Sparkplug in 2021 as a fast baseline compiler sitting between Ignition and TurboFan.

Sparkplug compiles bytecode directly to machine code with almost zero optimization — it’s designed to be as fast as possible, not to produce the best code. It’s useful for functions that run frequently enough that interpretation overhead matters, but not frequently enough to justify TurboFan’s expensive analysis.

The pipeline now looks like:

Source → AST → Bytecode (Ignition) → Baseline Machine Code (Sparkplug) → Optimized Machine Code (TurboFan)

Sparkplug fills the gap between cold interpretation and full JIT optimization, reducing the “warming up” latency you’d otherwise see.


Stage 4: TurboFan — The Optimizing JIT Compiler

As your program runs, Ignition tracks type feedback: which types of values each variable holds, which branches get taken, how often each function is called. Functions that get called frequently are called hot functions.

When a function becomes hot, V8 hands it off to TurboFan, the optimizing JIT compiler. TurboFan uses the collected type feedback to make aggressive assumptions and compile highly optimized machine code.

How JIT Optimization Works

1. Speculation

TurboFan sees that add(a, b) has always been called with two numbers. It assumes this will always be true and generates machine code that skips all the type-checking overhead:

; Optimized: just adds two CPU registers directly
mov eax, [arg_a]
add eax, [arg_b]
ret

This is dramatically faster than the generic bytecode path.

2. De-optimization (Bailout)

If the assumption breaks — someone calls add("hello", 5) — TurboFan deoptimizes: it discards the optimized code, reverts to the Ignition bytecode interpreter, and starts collecting new type feedback. The function may later be re-optimized with broader assumptions.

De-optimization has a cost. Code that oscillates between optimized and deoptimized states is called megamorphic and performs poorly.


Hidden Classes: How V8 Tracks Object Shape

JavaScript objects are dynamic — you can add and remove properties at any time. For a statically-typed language like C++, the memory layout of objects is known at compile time. V8 has to figure this out at runtime.

V8 solves this with hidden classes (also called “shapes” or “maps”). Every object gets an associated hidden class that describes its property layout.

function Point(x, y) {
    this.x = x;
    this.y = y;
}

const p1 = new Point(1, 2);
const p2 = new Point(3, 4);

Both p1 and p2 share the same hidden class because they have identical property layouts. V8 can optimize property access as a fixed memory offset — just like a C struct.

Property Addition Order Matters

// Fast: same hidden class
const a = {};
a.x = 1;
a.y = 2;

const b = {};
b.x = 1;
b.y = 2;

// Slow: different hidden classes!
const c = {};
c.y = 2;
c.x = 1;

c gets a different hidden class from a and b because properties were added in a different order. V8 can’t reuse the same optimized access pattern for both.

Rule: Always add properties in the same order. Prefer object literals over dynamic property assignment in hot paths.


Inline Caching: Speeding Up Property Access

Every time you access a property (obj.name), V8 has to look it up. Inline caching (IC) is an optimization that caches the result of that lookup so the next access is instant.

Monomorphic (Fast)

If a call site always sees the same hidden class, the IC is monomorphic — V8 hard-codes the memory offset and skips the lookup:

function getName(person) {
    return person.name; // Always a { name, age } object → monomorphic IC
}

Polymorphic (OK)

If the call site sees 2–4 different hidden classes, it’s polymorphic — V8 checks a small list of cached classes:

getName({ name: 'Alice', age: 30 });
getName({ name: 'Bob', role: 'admin' }); // Different shape

Megamorphic (Slow)

More than 4 different shapes at the same call site → V8 gives up caching and falls back to a generic slow lookup. This is a significant performance hit in hot loops.

Rule: Keep objects at the same call site consistent in shape. Avoid passing wildly different object structures to the same function.


End-to-End Flow Summary

JavaScript Source Code

    Tokenizer

    Parser → AST

    Ignition → Bytecode + Type Feedback

    Sparkplug → Baseline Machine Code
        ↓ (hot functions only)
    TurboFan → Optimized Machine Code
        ↓ (if assumptions break)
    De-optimization → back to Ignition
StageToolGoal
ParsingLexer + ParserSource → AST
InterpretationIgnitionAST → Bytecode, collect feedback
Baseline JITSparkplugFast machine code, no optimization
Optimizing JITTurboFanSpeculative optimized machine code
RecoveryDeoptimizerBail out when assumptions fail

Writing Engine-Friendly JavaScript

Understanding V8 internals leads to concrete coding practices:

1. Keep object shapes consistent Initialize all properties in the constructor. Don’t add properties conditionally after creation.

// Good
class User {
    constructor(name, age) {
        this.name = name;
        this.age = age;
    }
}

// Bad — sometimes role exists, sometimes not
function createUser(name, age, isAdmin) {
    const user = { name, age };
    if (isAdmin) user.role = 'admin'; // Different shape!
    return user;
}

2. Don’t change variable types If a variable starts as a number, keep it a number. Type changes force deoptimization.

let count = 0;     // Optimized as integer
count = 'reset';   // Forces deoptimization of all code using count

3. Avoid delete on hot objects Deleting a property changes the hidden class and breaks inline caches.

4. Use typed arrays for numeric data Int32Array, Float64Array, etc. have fixed types — V8 can optimize them like C arrays.

5. Keep functions small and focused Smaller functions are easier for TurboFan to analyze and optimize. Large functions with many branches are harder to speculate on.


Key Takeaways

  • V8 parses JavaScript into an AST, then compiles to bytecode via Ignition.
  • Sparkplug provides fast baseline machine code before TurboFan’s expensive analysis kicks in.
  • TurboFan speculatively optimizes hot functions based on observed type feedback — and deoptimizes when assumptions break.
  • Hidden classes let V8 treat dynamic JavaScript objects like statically-typed structs. Consistent property order and shape are critical.
  • Inline caches speed up property access. Megamorphic call sites (4+ shapes) kill performance.
  • Write consistent, predictable code and V8 will reward you with optimized machine code.
Aditya Rawas

Written by

Aditya Rawas

Full-stack engineer writing deep-dives on JavaScript, TypeScript, React, AWS, Docker, and Kubernetes. Passionate about making complex engineering concepts accessible to developers at every level.