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 executionV8 (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 literal2. 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 +undefinedare hoistedlet/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 accumulatorBytecode 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):
- Ignition — every function lands here as bytecode. Cheap to start.
- Sparkplug (2021) — bytecode → simple machine code. Fast baseline JIT.
- Maglev (2023) — mid-tier optimizing JIT. Inline caches.
- 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; // addNaive 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 happyInline 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 stepDifferent 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
└────────── syncClosures — what scope chains buy you
function counter() {
let n = 0;
return () => ++n;
}
const inc = counter();
inc(); // 1
inc(); // 2The 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 ASI2. 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 unchanged4. 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, fast5. 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
- V8 Ignition / TurboFan blog — v8.dev
- ECMAScript ASI rules — TC39
- AST Explorer — astexplorer.net
- V8 hidden classes — v8.dev
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.