DEV Community

Surya Kanth
Surya Kanth

Posted on

JavaScript & CS Execution Internals: A Deep Dive

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

  1. Parser — produces an AST; a pre-parser skips function bodies not yet needed (lazy parsing) to keep startup fast.
  2. Ignition — a register-based bytecode interpreter. Every function starts here. Cheap to enter, slow to run repeatedly.
  3. 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.
  4. 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.
  5. 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
Enter fullscreen mode Exit fullscreen mode

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 int is 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 }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Practical traps

  • Global object leakage — an un-declared assignment (x = 5 with no var/let/const, outside strict mode) creates a property on globalThis/window. var at the top level also adds a property to globalThis; top-level let/const do 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);
}
Enter fullscreen mode Exit fullscreen mode

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 null bug — 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 be 000, the same bits as the null pointer. null was 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 — typeof is specifically safe to call on a variable that was never declared at all (returns "undefined", no error) — but calling it on a let/const that's declared later in the same scope throws, because the TDZ check happens before typeof'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;
Enter fullscreen mode Exit fullscreen mode

Limitations of typeof / instanceof

  • typeof collapses every object type — arrays, dates, regexes, plain objects, null — into "object". It cannot distinguish them.
  • instanceof walks the prototype chain, so it breaks across realms: an array created in an <iframe> fails arr instanceof Array in the parent window, because each realm has its own Array.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 as f()) and, for constructible functions, a [[Construct]] slot (invoked as new f()). Arrow functions are [[Call]]-only.
  • new.target is set to the constructor when a function is invoked via new, and undefined otherwise — the standard way to detect "was I called as a constructor?"
  • Arrow functions bind this lexically 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 outer this.
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);
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)