01 — Engine Architecture, Runtime Model & Execution Pipeline
How a JS Engine Executes Your Code
javascript
// ── The JavaScript execution pipeline (V8, SpiderMonkey, JSCore) ──
//
// 1. PARSE: Source code → AST (Abstract Syntax Tree)
// - Lexer/tokenizer: characters → tokens (keywords, identifiers, literals)
// - Parser: tokens → AST (nested node tree representing program structure)
// - Early errors (syntax) are thrown here — before any code runs.
//
// 2. COMPILE (JIT): AST → bytecode (+ tiered optimization to machine code)
// - Interpreter (Ignition in V8): AST → bytecode, executes immediately
// - Profiler: watches "hot" functions (called many times)
// - Optimizing compiler (TurboFan in V8): hot bytecode → optimized machine code
// - Deoptimization: if assumptions break (type changes), falls back to bytecode
//
// 3. EXECUTE: bytecode/machine code runs on the VM
// - Call stack: LIFO stack of execution contexts (frames)
// - Heap: memory allocation for objects, closures, references
// - GC (Orinoco in V8): generational, mark-and-sweep garbage collector
// ── Execution context structure (created for each function call) ──
// Every execution context has:
// 1. Variable Environment: var declarations, function declarations (hoisted)
// 2. Lexical Environment: let/const declarations (TDZ), closures
// 3. ThisBinding: the `this` value for this context
// 4. Outer: reference to the parent (outer) lexical environment (for scope chain)
// ── Hoisting: what actually happens during context creation ──
console.log(typeof greet); // "function" — function declarations are fully hoisted
console.log(typeof x); // "undefined" — var is hoisted and initialized to undefined
console.log(typeof y); // "ReferenceError" — let/const are hoisted but in TDZ
function greet() { return "hello"; } // hoisted: declaration + body
var x = 10; // hoisted: declaration only (x = undefined initially)
let y = 20; // hoisted: declaration only, in TDZ until line executes
// ── The call stack (LIFO — Last In, First Out) ──
function a() { b(); console.log("a"); } // a pushes frame, calls b
function b() { c(); console.log("b"); } // b pushes frame, calls c
function c() { console.log("c"); } // c pushes frame, returns
a();
// Call stack during execution:
// [a] → [a, b] → [a, b, c] → c returns → [a, b] → b returns → [a] → a returns → []
// Output: "c", "b", "a" (LIFO — last called, first to complete)
// ── Stack overflow: no tail-call optimization in V8 (only Safari/JSCore) ──
function recurse(n) {
if (n === 0) return;
recurse(n - 1); // each call adds a frame — no TCO in Chrome/Node
}
recurse(10000); // RangeError: Maximum call stack size exceeded (in V8)
// Safari/JSCore: supports TCO in strict mode — tail calls don't grow the stack
// 'use strict'; function tailRecurse(n, acc = 0) { return n === 0 ? acc : tailRecurse(n - 1, acc + n); }
Anti-Pattern: Blocking the Main Thread
javascript
// ❌ NAIVE — synchronous heavy computation blocks the event loop
// JS is single-threaded: one call stack, one event loop, one heap.
// While this runs, NOTHING else can: no UI updates, no timers, no I/O callbacks.
function blockingFibonacci(n) {
return n <= 1 ? n : blockingFibonacci(n - 1) + blockingFibonacci(n - 2);
}
// blockingFibonacci(45); // ~5 seconds of frozen UI — the entire main thread is blocked
// ✅ CORRECT — chunk work with setTimeout/scheduler to yield to the event loop
// Each chunk runs, then yields control back so the event loop can process other tasks.
async function chunkedFibonacci(n, chunkSize = 1000) {
return new Promise(resolve => {
let a = 0n, b = 1n, i = 0;
function chunk() {
const end = Math.min(i + chunkSize, n);
for (; i < end; i++) {
[a, b] = [b, a + b]; // BigInt — no precision loss for large numbers
}
if (i < n) {
setTimeout(chunk, 0); // yield to event loop — other callbacks can run
} else {
resolve(a.toString());
}
}
chunk();
});
}
// chunkedFibonacci(100000).then(r => console.log(r)); // UI stays responsive
V8's Tiered Compilation Pipeline
javascript
// ── V8's JIT pipeline (Ignition + TurboFan) ──
//
// Source → AST → Ignition (bytecode interpreter)
// ↓ (hot function detected)
// TurboFan (optimizing compiler)
// ↓ (assumptions broken)
// Deoptimize → back to bytecode
// ── Type speculation: TurboFan optimizes based on observed types ──
function add(a, b) { return a + b; }
add(1, 2); // TurboFan sees: always numbers → compiles to integer add
add(1, 2); // hot function — optimized machine code (fast path)
add(1, 2);
add("x", "y"); // ✗ type changed to string! → DEOPTIMIZE → falls back to bytecode
// After deopt, V8 may re-optimize with a more general (slower) version.
// ── Hidden classes (Shapes/Maps): V8's object layout optimization ──
// V8 assigns objects a "hidden class" (internal shape) based on their properties.
// Same property order → same hidden class → fast property access (inline caches).
function Point(x, y) {
this.x = x; // Point class created with {x}
this.y = y; // class transitioned to {x, y}
}
const p1 = new Point(1, 2); // hidden class: Point{x, y}
const p2 = new Point(3, 4); // same hidden class: Point{x, y} → fast!
// ❌ Property order matters — this creates a DIFFERENT hidden class:
function BadPoint(x, y) {
this.y = y; // BadPoint class created with {y} — different transition path
this.x = x; // transitioned to {y, x} — different from Point{x, y}
}
const p3 = new BadPoint(1, 2); // hidden class: BadPoint{y, x} — NOT same as Point
// ❌ Adding properties after construction causes class transitions (slow):
const p4 = new Point(5, 6);
p4.z = 7; // class transition: Point{x, y} → Point{x, y, z} (new hidden class)
// All future Point instances still use Point{x, y} — p4 is a different shape now.
// ✅ Declare all properties in the constructor for consistent hidden classes:
function FastPoint(x, y, z = 0) {
this.x = x;
this.y = y;
this.z = z; // all properties declared upfront — single hidden class
}
Node.js vs Browser Runtime
javascript
// ── Browser runtime model ──
// ┌──────────────────┐ ┌───────────────────┐
// │ JS Engine (V8) │ │ Web APIs (C++) │
// │ Call Stack │ │ DOM, fetch, │
// │ Heap (GC) │ │ setTimeout, XHR │
// └────────┬─────────┘ └─────────┬─────────┘
// │ │
// │ ┌──────────────────────┘
// │ │
// ┌────────▼───▼────────┐
// │ Event Loop │
// │ (libuv in Node, │
// │ browser-native) │
// │ │
// │ ┌───────────────┐ │
// │ │ Microtask Q │ │ ← Promise.then, queueMicrotask, MutationObserver
// │ │ (drained fully)│ │ (runs AFTER each macrotask, before next)
// │ └───────────────┘ │
// │ ┌───────────────┐ │
// │ │ Macrotask Q │ │ ← setTimeout, setInterval, I/O, UI events
// │ │ (one per tick) │ │ (one macrotask per event loop tick)
// │ └───────────────┘ │
// └─────────────────────┘
// ── Node.js runtime model ──
// Same V8 engine, but different host environment:
// - No DOM, no window, no document
// - Has: process, Buffer, __dirname, __filename, require()
// - libuv provides the event loop (cross-platform async I/O)
// - Timers: setTimeout/setInterval (from Node, not window)
// - Microtasks: process.nextTick (Node-specific, higher priority than Promises)
// ── Node-specific APIs (not available in browser) ──
const fs = require('fs'); // file system
const http = require('http'); // HTTP server/client
const path = require('path'); // path manipulation
const { Worker } = require('worker_threads'); // true parallelism
// ── Browser-specific APIs (not available in Node) ──
// window, document, localStorage, fetch (Node 18+ has fetch)
// DOM manipulation, CSS, Canvas, Web Audio, etc.
// ── Shared: the language is the same, the platform APIs differ ──
// Pure JS (Array methods, Promises, closures, classes) works identically in both.
The Event Loop: Microtasks vs Macrotasks
javascript
// ── Event loop tick (one iteration) ──
// 1. Execute one macrotask (setTimeout callback, I/O callback, event handler)
// 2. Drain the microtask queue (ALL microtasks: Promise.then, queueMicrotask)
// - Microtasks run to completion before the next macrotask
// - New microtasks added during step 2 also run in the same drain
// 3. Render (browser only: requestAnimationFrame callbacks, layout, paint)
// 4. Repeat
console.log("1: sync");
setTimeout(() => console.log("4: macrotask"), 0);
Promise.resolve().then(() => console.log("3: microtask"));
console.log("2: sync");
// Output: 1, 2, 3, 4
// 1: synchronous code runs first (script is itself a macrotask)
// 2: continues synchronously
// 3: microtask runs after current script (drained before next macrotask)
// 4: macrotask (setTimeout) runs after all microtasks are drained
// ── Microtask starvation: microtasks can block macrotasks ──
// If a microtask keeps adding more microtasks, macrotasks NEVER run:
// (Don't run this — it blocks forever)
// function infiniteMicrotasks() {
// Promise.resolve().then(infiniteMicrotasks);
// }
// infiniteMicrotasks(); // setTimeout callbacks never run — microtask queue never empties
// ── Node.js: process.nextTick vs Promise.then ──
// process.nextTick has HIGHER priority than Promise microtasks:
// Node only:
// process.nextTick(() => console.log("nextTick"));
// Promise.resolve().then(() => console.log("promise"));
// console.log("sync");
// Output: sync, nextTick, promise (nextTick drains before Promise microtasks)
Memory Model: Stack vs Heap
javascript
// ── Stack: primitive values, function frames, references ──
// ── Heap: objects, arrays, functions, closures (reference types) ──
let a = 42; // 42 stored on the stack (primitive)
let b = a; // copy: b gets its own 42 on the stack (pass by value)
a = 99; // a is 99, b is still 42 (independent copies)
const obj1 = { x: 1 }; // {x:1} allocated on the heap; obj1 is a reference on the stack
const obj2 = obj1; // obj2 copies the REFERENCE (points to same heap object)
obj1.x = 99; // mutates the shared heap object
console.log(obj2.x); // 99 — obj2 sees the change (same object)
// ── Garbage collection (GC) ──
// V8 uses a generational mark-and-sweep GC:
// - Young generation (nursery): new objects, short-lived. Scavenged (copied) frequently.
// - Old generation: survived objects, long-lived. Mark-and-swept less frequently.
// An object is collected when no references to it remain on the stack or in other heap objects.
function createLeak() {
const huge = new Array(1_000_000).fill("*"); // allocated on heap
// If `huge` is captured by a closure or stored globally, it can't be GC'd.
return () => huge.length; // closure captures `huge` — keeps it alive
}
const leak = createLeak(); // huge array stays alive as long as `leak` exists
leak = null; // NOW the huge array can be GC'd (no more references)
// ── WeakRef: hold a reference without preventing GC ──
const target = { data: "important" };
const weakRef = new WeakRef(target);
// weakRef.deref() returns the object if still alive, or undefined if GC'd
console.log(weakRef.deref()); // { data: "important" } or undefined if GC'd
// Use case: caches that shouldn't prevent garbage collection
💡 Tips & Tricks
javascript
// ── Check your runtime (browser vs Node) ──
const isNode = typeof process !== "undefined" && process.versions?.node;
const isBrowser = typeof window !== "undefined";
const isWorker = typeof self !== "undefined" && typeof window === "undefined";
// ── Feature-detect without UA sniffing ──
const supportsWeakRef = typeof WeakRef !== "undefined";
const supportsTopLevelAwait = true; // ES2022+ — check by trying
// ── Use scheduler.yield() (Chrome 129+) to yield without setTimeout ──
// Modern alternative to setTimeout(fn, 0) for yielding:
async function yieldToEventLoop() {
if (typeof scheduler !== "undefined" && scheduler.yield) {
await scheduler.yield(); // higher-priority than setTimeout
} else {
await new Promise(r => setTimeout(r, 0)); // fallback
}
}
// ── Measure engine performance with performance.now() ──
const start = performance.now();
// ... code to measure ...
const elapsed = performance.now() - start; // milliseconds with sub-ms precision
// performance.now() is monotonic (never goes backwards) and high-resolution.
// ── Detect JIT deoptimization (V8) with %GetOptimizationStatus ──
// Requires: node --allow-natives-syntax
// function hot() { return 1 + 2; }
// hot(); hot(); hot(); // warm up
// print(%GetOptimizationStatus(hot)); // 1 = optimized, 0 = not optimized
// (Debug-only — don't use in production)
⚠️ Edge Cases & Gotchas
javascript
// ── `instanceof` breaks across realms (iframes, Node worker_threads) ──
// Each realm has its own copy of built-in constructors.
// Node:
const { Worker } = require("worker_threads");
// An array created in a Worker is NOT instanceof Array in the main thread:
// const w = new Worker('module.exports = new Array()');
// w.on('message', arr => console.log(arr instanceof Array)); // false!
// (Each realm has its own Array constructor → different prototypes)
// Use Array.isArray(arr) instead — works across realms.
// ── `typeof null === "object"` is a legendary bug (not fixable — would break the web) ──
// In the original JS implementation, type tags used 3 bits:
// 000: object, 001: integer, 010: double, 100: string, 110: boolean
// null was represented as the NULL pointer (0x00), which looked like type tag 000 (object).
console.log(typeof null); // "object" (bug, since 1995)
console.log(null instanceof Object); // false (null is NOT an Object instance)
// Correct null check: val === null (strict equality)
// ── `[]` and `{}` are truthy (unlike Python where they're falsy) ──
if ([]) console.log("empty array is truthy"); // prints!
if ({}) console.log("empty object is truthy"); // prints!
if (0) console.log("0 is falsy"); // does NOT print
// Only 6 falsy values: false, 0, -0, 0n, "", null, undefined, NaN
// (Empty array/object are NOT in the falsy list)
// ── `NaN` is the only value not equal to itself ──
console.log(NaN === NaN); // false
console.log(Number.isNaN(NaN)); // true (correct way)
console.log(Object.is(NaN, NaN)); // true (Object.is handles NaN and -0)
🧠 Quick Quiz
What's the output order?
javascript
console.log("A");
setTimeout(() => console.log("B"), 0);
Promise.resolve().then(() => console.log("C"));
console.log("D");
queueMicrotask(() => console.log("E"));
Answer
A
D
C
E
B
A— synchronous (script is a macrotask, runs first)D— synchronous (continues)C— microtask (Promise.then, drained after script)E— microtask (queueMicrotask, same microtask drain)B— macrotask (setTimeout, runs after all microtasks drained)
The lesson: all microtasks (Promise.then, queueMicrotask) run before the next macrotask (setTimeout). The microtask queue is fully drained before the event loop moves to the next macrotask.