Part 1 — Execution Architectures: Interpreters, Compilers, and JIT
JavaScript engines pick a point on a spectrum: interpret everything for fast startup, compile everything ahead of time for fast steady-state speed, or do both with a JIT that promotes hot code as it runs.
Three foundational models
- Pure interpreter — parses source to an AST (or a simple bytecode) and walks it node by node at run time. No machine code is ever produced; every operation re-dispatches through the interpreter's switch loop.
- Ahead-of-time (AOT) compiler — runs the full pipeline before execution: lexing → AST → semantic analysis → intermediate representation (IR) → optimization passes → target machine code. The binary that ships is already native code.
- JIT (hybrid) — starts by interpreting (or running a fast baseline compile) so the program starts immediately, then profiles which functions run often ("warm"/"hot"), recompiles just those into optimized machine code, and can de-optimize (bail out) back to a slower tier if an assumption the optimizer made turns out to be wrong.
| Model | How code runs | Startup latency | Peak throughput | Typical example |
|---|---|---|---|---|
| Pure interpreter | AST/bytecode walked live | Lowest | Lowest | Early Ruby MRI, shell scripts |
| AOT compiler | Native machine code from disk | Highest (compile first) | Highest, consistent | C, Rust, Go |
| JIT hybrid | Interpret → profile → compile hot paths | Low | High after warm-up | V8 (JS), JVM, .NET CLR |
flowchart TD
subgraph AOT["Pure AOT Compiler"]
A1[Source] --> A2[Lex/Parse to AST] --> A3[Semantic Analysis] --> A4[IR + Optimization] --> A5[Native Machine Code] --> A6[Run at full speed]
end
subgraph INT["Pure Interpreter"]
B1[Source] --> B2[Parse to AST] --> B3[Walk AST / dispatch loop] --> B4[Re-walked every call]
end
subgraph JIT["V8 Multi-Tier JIT"]
C1[Source] --> C2[Parser: AST]
C2 --> C3[Ignition: Bytecode Interpreter]
C3 -- warm --> C4[Sparkplug: Baseline Compiler]
C4 -- hotter --> C5[Maglev: Mid-tier Optimizer]
C5 -- hottest --> C6[TurboFan: Optimizing Compiler]
C6 -- assumption broken --> C3
end
Reading it: AOT pays the compile cost once, before the user ever sees output. A pure interpreter pays a re-dispatch cost on every single execution. V8's JIT tries to get both — cheap startup from Ignition, native speed from TurboFan for code that's actually worth optimizing — and TurboFan can hand control back to Ignition (de-opt) if a hot function starts seeing shapes/types it didn't expect.
V8's multi-tier pipeline
- Parser — produces an AST; a pre-parser skips function bodies not yet needed (lazy parsing) to keep startup fast.
- Ignition — a register-based bytecode interpreter. Every function starts here. Cheap to enter, slow to run repeatedly.
- Sparkplug — a non-optimizing baseline compiler. Once a function is called enough to be "warm," V8 compiles its bytecode almost 1:1 into machine code — no deep analysis, just removing interpreter dispatch overhead.
- Maglev — a mid-tier optimizing compiler (shipped in recent V8) that does real optimization (type feedback, inlining) but compiles fast, sitting between Sparkplug's cheapness and TurboFan's aggressiveness.
- TurboFan — the top-tier optimizing compiler for genuinely hot functions. Uses collected type feedback (inline caches) to speculate — e.g., "this property access always hits the same object shape" — and emits tightly optimized machine code. If a speculative assumption is violated (a new shape appears, a value's type changes), V8 de-optimizes: it throws away the optimized code and falls back to Ignition/Sparkplug, recollecting feedback before trying to re-optimize.
// Inline caching + deopt, illustrated
function getX(obj) {
return obj.x; // TurboFan inlines this once it sees only one "shape" of obj
}
for (let i = 0; i < 100000; i++) {
getX({ x: i, y: 0 }); // consistent shape -> monomorphic -> gets optimized
}
getX({ y: 0, x: 99 });
// property order differs -> different hidden class/shape
// -> inline cache goes "polymorphic", TurboFan may de-optimize getX
// back to Ignition until it re-learns the new shape's feedback
Takeaway: the same JS source can run through four different "engines" over its lifetime — which is why microbenchmarks that only run a loop a few times can be measuring Ignition, not TurboFan.
Part 2 — Type Systems: Static vs. Dynamic, Strong vs. Weak
Two independent questions get conflated constantly: when are types checked (static = compile time, dynamic = run time), and how strictly are mismatched types tolerated (strong = errors/refuses, weak = silently coerces).
The typing matrix
| Language | Check timing | Coercion strictness | Cell |
|---|---|---|---|
| JavaScript | Dynamic | Weak |
"5" + 3 === "53", "5" - 3 === 2
|
| Python | Dynamic | Strong |
"5" + 3 raises TypeError
|
| C++ | Static | Weak | implicit int→float, pointer arithmetic |
| Java | Static | Strong | no silent numeric-to-string coercion |
| TypeScript | Static (erased at runtime) | Strong at compile time, weak at runtime | compiler blocks "5" + 3 mismatches; compiled JS still coerces |
flowchart LR
subgraph Timing
direction TB
ST[Static: checked at compile time]
DY[Dynamic: checked at run time]
end
subgraph Strictness
direction TB
SW[Weak: silently coerces]
SS[Strong: refuses mismatches]
end
ST --- SS --- Java[Java]
ST --- SW --- Cpp[C++]
DY --- SS --- Python[Python]
DY --- SW --- JS[JavaScript]
ST -.compiles to.-> DY
TS[TypeScript] -. static + strong, erased .-> JS
TypeScript sits diagonally from JS: it adds static, strong checking on top, but the checks vanish at compile-out — the emitted JS is exactly as dynamic and weak as ever.
Memory & engine implications
Static typing lets the compiler commit to a memory layout before the program runs; dynamic typing forces the engine to keep re-discovering the "shape" of a value while it runs.
-
Static (e.g. a C struct) — fields sit at fixed, known byte offsets in contiguous memory. Primitives are unboxed (an
intis 4 raw bytes, not a pointer to an object). The CPU can prefetch and cache this layout efficiently. - Dynamic (e.g. a JS object) — values are commonly boxed (wrapped in a heap object carrying a type tag) and accessed through tagged pointers; numbers may use NaN-boxing (packing a float or a pointer into the unused bit patterns of a 64-bit NaN) to avoid allocating a box for every number. V8 recovers some of static typing's speed with hidden classes / shapes: objects with the same property names, in the same insertion order, share one hidden class, so property lookup can become an offset lookup instead of a hash-map lookup — until a shape diverges.
STATIC: struct Point { int x; int y; };
┌──────────┬──────────┐
│ x: 4B │ y: 4B │ <- one contiguous stack/struct slot, fixed offsets
└──────────┴──────────┘
DYNAMIC: const p = { x: 1, y: 2 };
stack slot ──▶ [ tagged pointer ] ──▶ HEAP: [ hidden-class ptr | x slot | y slot ]
│
▼
HiddenClass{ x:offset0, y:offset1 }
Reading it: a static struct is data, in place. A dynamic object is a pointer to data, plus a separate description of what that data means — one extra hop, paid on every property access, that engines fight to eliminate with hidden classes and inline caches.
// Hidden-class divergence in practice
const a = { x: 1, y: 2 };
const b = { x: 1, y: 2 }; // same shape as a -> shares a hidden class
const c = { y: 2, x: 1 }; // different insertion order -> DIFFERENT hidden class
delete a.y; // mutating shape after creation -> a gets its own
// new (slower, dictionary-mode-prone) hidden class
Part 3 — Variable Declaration & Scope: var vs. let vs. const
Scope granularity
| Scope | Created by | Example |
|---|---|---|
| Global | Top-level of a script |
var x = 1; outside any function |
| Function | Any function body |
var inside function f(){}
|
| Block |
{ }, if, for, while
|
let/const inside { }
|
| Module | ES module file | top-level let in a .mjs/type="module" file — not attached to globalThis
|
var only respects function/global scope; let/const respect block scope.
The variable lifecycle
The engine processes a scope in two conceptual passes: it first walks the code to register every declaration in the environment (LexicalEnvironment for let/const/classes, VariableEnvironment for var/function declarations), then executes statements top to bottom, initializing and later assigning values.
stateDiagram-v2
[*] --> Unregistered
state "var path" as VarPath {
Unregistered --> Registered_Initialized_Undefined: creation phase\n(hoisted + auto-initialized to undefined)
Registered_Initialized_Undefined --> Assigned: execution reaches `x = value`
}
state "let/const path" as LetPath {
Unregistered --> TDZ: creation phase\n(hoisted, NOT initialized)
TDZ --> Initialized: execution reaches the `let`/`const` line
Initialized --> Assigned: `let` only — `const` is initialized+assigned together
}
Assigned --> [*]
Reading it: var is hoisted AND pre-filled with undefined, so reading it early gives undefined, not an error. let/const are hoisted but left in the **Temporal Dead Zone* — reading them before their own declaration line throws ReferenceError, because they exist but aren't initialized yet.*
console.log(a); // undefined — hoisted + auto-initialized (var)
var a = 1;
console.log(b); // ReferenceError: Cannot access 'b' before initialization
let b = 2; // TDZ ends exactly here
Practical traps
-
Global object leakage — an un-declared assignment (
x = 5with novar/let/const, outside strict mode) creates a property onglobalThis/window.varat the top level also adds a property toglobalThis; top-levellet/constdo not. -
The async
for-loop trap — classic interview question:
// var: ONE shared binding across all iterations
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0); // logs 3, 3, 3
}
// let: a FRESH lexical binding created per iteration
for (let j = 0; j < 3; j++) {
setTimeout(() => console.log(j), 0); // logs 0, 1, 2
}
// pre-ES6 fix: force a new scope per iteration with an IIFE
for (var k = 0; k < 3; k++) {
(function (frozenK) {
setTimeout(() => console.log(frozenK), 0); // logs 0, 1, 2
})(k);
}
With var, every closure captures the same variable, whose final value (3) is what all three callbacks see after the loop finishes. let creates a new binding on each iteration, so each closure captures its own snapshot. The IIFE pattern manufactures that same per-iteration binding manually, by passing k as a function argument that gets its own scope on each call.
Part 4 — Runtime Type Inspection: typeof and Beyond
The spec return values
typeof returns exactly one of eight strings: "undefined", "boolean", "number", "string", "bigint", "symbol", "object", "function".
-
The
nullbug —typeof null === "object". This is a famous historical artifact: early JS engines tagged values with a type bitmask in the low bits of the value's representation, and the tag for "object" happened to be000, the same bits as the null pointer.nullwas never actually an object; the tag check just couldn't tell the difference, and the behavior shipped and then had to stay for compatibility forever. -
Undeclared vs. TDZ —
typeofis specifically safe to call on a variable that was never declared at all (returns"undefined", no error) — but calling it on alet/constthat's declared later in the same scope throws, because the TDZ check happens beforetypeof's usual "don't throw" special-casing gets a chance:
typeof neverDeclared; // "undefined" — no ReferenceError
typeof inTDZ; // ReferenceError: Cannot access 'inTDZ' before initialization
let inTDZ = 5;
Limitations of typeof / instanceof
-
typeofcollapses every object type — arrays, dates, regexes, plain objects,null— into"object". It cannot distinguish them. -
instanceofwalks the prototype chain, so it breaks across realms: an array created in an<iframe>failsarr instanceof Arrayin the parent window, because each realm has its ownArray.prototype.
Reliable alternatives
| Need | Tool | Example |
|---|---|---|
| Precise built-in tag, cross-realm safe | Object.prototype.toString.call(x) |
Object.prototype.toString.call([]) === "[object Array]" |
| Is it specifically an array? | Array.isArray(x) |
Works across realms, unlike instanceof Array
|
| Custom/branded shape check | User-defined type guard | function isUser(x) { return x != null \&\& typeof x.id === "string"; } |
flowchart TD
Start["What are you checking?"] --> Q1{"Primitive?\n(number/string/boolean/\nundefined/symbol/bigint)"}
Q1 -- yes --> UseTypeof["Use typeof"]
Q1 -- no, it's an object --> Q2{"Need to know if it's\nspecifically an Array?"}
Q2 -- yes --> UseIsArray["Use Array.isArray(x)"]
Q2 -- no --> Q3{"Need the exact built-in kind\n(Object/Date/RegExp/Map...)?"}
Q3 -- yes --> UseToString["Use Object.prototype.toString.call(x)"]
Q3 -- no --> Q4{"Checking your OWN class,\nsame realm guaranteed?"}
Q4 -- yes --> UseInstanceof["instanceof is fine here"]
Q4 -- no / cross-realm risk --> UseGuard["Write a custom type guard\n(duck-type on known shape)"]
Rule of thumb: reach for typeof on primitives, Array.isArray for arrays specifically, Object.prototype.toString.call when you need the precise built-in kind of any object, and a hand-written guard when checking your own data shapes across possible realm boundaries.
Part 5 — Functions: First-Class Citizens & Execution Context
First-class status
Functions are ordinary values in JS: they can be assigned to variables, passed as arguments (callbacks), returned from other functions, and stored in data structures. A higher-order function is simply one that takes or returns another function (Array.prototype.map, setTimeout).
Function forms & spec differences
| Form | Hoisted with body? | Has own this
|
Has arguments
|
[[Construct]] (usable with new) |
|---|---|---|---|---|
Declaration function f(){}
|
Yes | Yes (dynamic) | Yes | Yes |
Expression const f = function(){}
|
No (var-hoisted as undefined if var) |
Yes (dynamic) | Yes | Yes (if named/non-arrow) |
Arrow const f = () => {}
|
No | No — inherits lexically from enclosing scope | No — must use rest params ...args
|
No — throws TypeError
|
- Every callable object internally has a
[[Call]]slot (invoked asf()) and, for constructible functions, a[[Construct]]slot (invoked asnew f()). Arrow functions are[[Call]]-only. -
new.targetis set to the constructor when a function is invoked vianew, andundefinedotherwise — the standard way to detect "was I called as a constructor?" - Arrow functions bind
thislexically at definition time (from the enclosing scope) rather than dynamically at call time, which is precisely why they're preferred for callbacks that need to preserve an outerthis.
function RegularCounter() {
this.count = 0;
setTimeout(function () {
this.count++; // `this` here is NOT the RegularCounter instance
}, 100); // (it's undefined in strict mode, or the global object otherwise)
}
function ArrowCounter() {
this.count = 0;
setTimeout(() => {
this.count++; // arrow inherits `this` from ArrowCounter's own scope — works
}, 100);
}
Execution context & the scope chain
Every function call pushes a new execution context onto the call stack, holding an Environment Record (the modern name for what used to be called the Activation Object) — the live bindings for that call's parameters and local variables. Each context also keeps a reference to its outer environment, forming the scope chain used to resolve free variables.
flowchart TD
subgraph Stack["Call Stack (LIFO)"]
direction TB
CS3["outer() context\n(Environment Record: count)"]
CS4["inner() context — pushed on call,\npopped when inner() returns"]
end
subgraph Heap["Heap"]
direction TB
H1["outer's Environment Record\n(kept ALIVE by closure reference,\neven after outer()'s stack frame is popped)"]
H2["inner (the returned function)\n[[Environment]] --> points to H1"]
end
CS3 -.captured by.-> H1
H2 -- "[[Environment]] link" --> H1
CS4 -."is a call to".-> H2
function outer() {
let count = 0; // lives in outer()'s Environment Record
function inner() {
count++; // resolved via the scope chain -> outer's record
return count;
}
return inner; // inner "closes over" outer's environment
}
const counter = outer(); // outer()'s STACK frame is popped here...
console.log(counter()); // ...but its Environment Record survives on the
console.log(counter()); // HEAP, kept alive because inner still references it
// logs: 1, 2
Reading it: outer()'s stack frame is torn down the moment it returns — that's normal stack discipline. But its Environment Record isn't stack-allocated; it's a heap object. As long as something reachable (here, inner's hidden [[Environment]] slot) still points to it, the garbage collector can't reclaim it — which is exactly what makes a closure work, and exactly why closures over large objects can quietly leak memory if the closure outlives its usefulness. Modern engines mitigate this with **ephemeron-like GC semantics: an environment record is only kept alive by references that are themselves reachable, so a closure retaining an unreachable object doesn't artificially keep that object alive forever once nothing else can reach the closure either — but a *long-lived closure (e.g. stored in a global cache) will keep everything in its scope chain alive for as long as it exists.*
Top comments (0)