Skip to content
yutils

How JavaScript Is Parsed and Executed

From source bytes to V8 hidden classes — lexer, parser, AST, hoisting, the event loop, ASI gotchas, and the optimizations that make JavaScript fast despite being a dynamic language.

~9 min read

JavaScript is dynamic and yet modern engines (V8, SpiderMonkey, JavaScriptCore) run it at near-native speeds. How? And why does var get hoisted, why does a newline after return break your object literal, why do Promise callbacks run before setTimeout? All of it is downstream of the parser → AST → optimizer → bytecode → JIT pipeline. This guide walks through how JavaScript is parsed and executed.

The full pipeline

source bytes
   ↓
1. Lexer       — characters → token stream
   ↓
2. Parser      — tokens → AST
   ↓
3. Bytecode    — AST → V8's Ignition bytecode
   ↓
4. Interpreter — execute bytecode (starts fast)
   ↓
5. Profiler    — detect hot functions
   ↓
6. JIT         — compile to machine code (TurboFan / Maglev)
   ↓
machine code execution

V8 (Chrome / Node), SpiderMonkey (Firefox), and JavaScriptCore (Safari) share this shape. Names and optimizations differ.

1. Lexer — source → tokens

source:
const x = 42 + foo();

tokens:
  KEYWORD     const
  IDENT       x
  PUNCT       =
  NUMBER      42
  PUNCT       +
  IDENT       foo
  PUNCT       (
  PUNCT       )
  PUNCT       ;

Whitespace and comments are skipped. The state machine reads char-by-char. A famous JS-specific challenge — the same character can mean different things:

/abc/   ← regex or division?
  context: after an identifier (a = /abc/) → division
           at statement start or after ( → regex literal

2. Parser — tokens → AST

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

AST excerpt:
FunctionDeclaration
├── name: "add"
├── params: [a, b]
└── body: BlockStatement
    └── ReturnStatement
        └── BinaryExpression
            ├── op: "+"
            ├── left: Identifier(a)
            └── right: Identifier(b)

Try it — astexplorer.net shows the AST of any JS instantly. Babel / ESLint / TypeScript / Prettier all share the same AST stage. So does JavaScript Formatter.

ASI — Automatic Semicolon Insertion

JavaScript lets you skip semicolons. The parser inserts them via a small set of rules — and those rules bite:

// Intent — return an object
function f() {
  return
  {
    name: "Alice"
  };
}

// What actually runs after ASI
function f() {
  return;            // ← returns undefined!
  { name: "Alice" }; // ← unreachable
}

Rule — a newline triggers ASI when the previous token is return, throw, break,continue, ++, or --.

Fix — open { on the same line, or use explicit semicolons.

3. Hoisting — a parser-stage outcome

// Source order
console.log(x);  // undefined (no error)
console.log(y);  // ReferenceError
var x = 1;
let y = 2;

The parser hoists declarations during scope analysis. But differently per declaration:

  • var — declaration + undefined are hoisted
  • let / const — declaration hoisted, initialization stays put. The gap is the temporal dead zone
  • Function declarations — fully hoisted (declaration + body)
  • Function expressions — behave like var

4. Bytecode — Ignition

V8's Ignition is a register-based VM. Running the AST directly is too slow, so it compiles to bytecode:

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

ignition bytecode (simplified):
Ldar a1            // load a into accumulator
Add a2             // accumulator += b
Return             // return accumulator

Bytecode is smaller and faster than the AST, slower than native code. Only hot functions get JIT-compiled.

5. JIT — TurboFan / Maglev

V8's JIT tiers (as of 2024):

  1. Ignition — every function lands here as bytecode. Cheap to start.
  2. Sparkplug (2021) — bytecode → simple machine code. Fast baseline JIT.
  3. Maglev (2023) — mid-tier optimizing JIT. Inline caches.
  4. TurboFan — top-tier. Strongest optimizations, slowest compile time. Hot loops only.

The profiler promotes hot functions up the tiers, and deoptimizes when assumptions break.

Hidden classes — fast dynamic objects

JS objects let you add fields whenever:

const a = {x: 1};
a.y = 2;       // add
a.z = 3;       // add

Naive impl — a dictionary lookup per access. Slow. V8's hidden class trick:

a = {x: 1}    → Class C0 [x: offset 0]
a.y = 2       → Class C1 [x: offset 0, y: offset 4]
a.z = 3       → Class C2 [x: offset 0, y: offset 4, z: offset 8]

Objects with the same shape share a hidden class. obj.x becomes a fixed-offset load — comparable to a C struct access.

The perf gotcha

// Bad — different shape per call → many hidden classes
function makeUser(name, age) {
  const u = {};
  if (name) u.name = name;
  if (age) u.age = age;
  return u;
}
makeUser("Alice", 30)    → {name, age}    → class A
makeUser(null, 30)       → {age}          → class B
makeUser("Alice")        → {name}         → class C
// 3 classes → polymorphic site → JIT can't specialize

// Good — always the same shape
function makeUser(name, age) {
  return {name: name || null, age: age || null};
}
// Always class X → monomorphic site → JIT happy

Inline caches — fast property access

function getName(obj) {
  return obj.name;
}

// Every call:
// 1. find obj's hidden class
// 2. find "name"'s offset in that class
// 3. memory access

// JIT learns "this site always sees class X" and caches the offset
// Subsequent calls — verify the class, use the cached offset → 1 step

Different hidden classes at the same site → polymorphic / megamorphic → cache misses. Prefer consistent object shapes.

Event loop — async at the core

while (true) {
  if (callStack.empty) {
    if (microtaskQueue.notEmpty) {
      runMicrotask();  // Promise.then, queueMicrotask
    } else if (macrotaskQueue.notEmpty) {
      runMacrotask();  // setTimeout, setInterval, I/O
    } else {
      sleep();
    }
  }
  render();  // every frame
}

Microtasks beat macrotasks. After the current tick, all queued microtasks run before one macrotask, then the frame can render.

console.log("1");
setTimeout(() => console.log("2"), 0);  // macrotask
Promise.resolve().then(() => console.log("3"));  // microtask
console.log("4");

Output: 1, 4, 3, 2
        │  │  │  │
        │  │  │  └─ macrotask
        │  │  └──── microtask (end of current tick)
        │  └─────── sync
        └────────── sync

Closures — what scope chains buy you

function counter() {
  let n = 0;
  return () => ++n;
}

const inc = counter();
inc();  // 1
inc();  // 2

The inner function captures the outer's n. During scope analysis, the parser sees the reference and promotes the outer's stack frame to the heap (escape analysis).

Memory risk — the inner keeps the outer's environment alive. A large object captured by accident never gets GC'd.

Bundlers and minifiers ride on the parser

  • Babel — transforms old JS → new (or vice versa). Parses, mutates AST, re-emits
  • esbuild / swc — Go/Rust parsers. 10-100× faster than JS-native parsers
  • Terser — minifier. Parses, mangles names, drops dead code, re-emits
  • Prettier / formatters — parse, pretty-print with style rules

JavaScript Formatter follows the same pattern — parse → AST → emit. JS Object → JSON accepts the relaxed JS object syntax (unquoted keys, trailing commas) and emits standard JSON.

Common pitfalls

1. ASI silent bugs

The return + newline trap. ++ / -- too:

a
++b    // not "a evaluated as ++b" — actually "a; ++b;" by ASI

2. Polymorphic hidden classes

Varying object shape per call site → JIT can't specialize.

3. delete obj.x

delete obj.x;
// Changes the hidden class to a dictionary mode → every access slows

// Prefer
obj.x = undefined;
// Hidden class unchanged

4. Array holes

const arr = [1, 2, 3];
arr[10] = 100;
// length 11, indices 3-9 are holes (sparse)
// V8 may switch to dictionary mode → slow

// Prefer
const arr = new Array(11).fill(undefined);
arr[10] = 100;
// Dense, fast

5. eval / with defeat JIT

Both change scope dynamically. The parser can't analyze statically, so the containing function isn't JIT-compiled. Avoid in modern code.

References

Summary

  • Pipeline — source → lexer → parser → AST → bytecode → interpreter → (hot) JIT → native code.
  • ASI inserts ; after newlines following return / throw / break / continue. Open { on the same line.
  • Hoisting differs — var (declaration + undefined), let/const (TDZ), function (full), function expressions (like var).
  • V8 hidden classes — same object shape = same class → monomorphic site → JIT optimizes. Avoid polymorphism.
  • Event loop — sync → microtasks → one macrotask → render. Promise.then before setTimeout.
  • Closures keep outer scope alive by promoting frames to the heap. Watch for memory leaks.
  • esbuild / Babel / Prettier all share the parser → AST → emit pattern.
  • Try it: JavaScript Formatter / JS Object → JSON or astexplorer.net for the raw AST.
Back to guides