Part 0 · 9 chapters · ~45 min

What Runs Your Code

JavaScript is a specification; an engine implements it; a host embeds the engine and gives it a world. This part draws that line, then the V8 pipeline from source text to machine code and back, the life of one function through its tiers, where startup time goes, the isolate and context that bound memory and globals, the engine's job queue versus the host's loop, and the tools that make every later claim checkable.

1

What JavaScript is, and what it is not

the question

"Is setTimeout part of JavaScript? Is the event loop? Is console.log?"

None of them. JavaScript is ECMAScript: a language specification that defines syntax, the type system, objects and prototypes, functions, promises and the job queue, the built-in library (Object, Array, Map, JSON, Math...), and the semantics of running a script or module. An engine implements that. Everything else your program touches is the host: the thing that embedded the engine and gave it a world to act in.

the line
  1. Engine (ECMAScript): parsing and evaluation; Object, Function, Array, String, Promise, Map, Symbol, Proxy, Reflect; the prototype chain; closures and scope; async/await and the microtask (job) queue; modules' linking and evaluation; garbage collection as an invisible service.
  2. Host (not ECMAScript): window, document, fetch, setTimeout, console, localStorage, process, require, fs, Buffer, WebSocket; the event loop and its task queues; the module loader that finds a file for an import specifier; the notion of "a script tag".
  3. Shared conventions, host-defined: globalThis exists in the spec; what is on it is the host's. console is standardised separately by WHATWG. setTimeout is HTML spec; Node reimplements it with different clamping.
why the line matters
  1. Portability of knowledge. A fact about hidden classes or GC is true in Chrome, Node, Deno, Electron and Cloudflare Workers, because they all embed V8. A fact about setTimeout(0) delay or fetch caching is true for one host.
  2. Where to look when something is slow. A slow loop is an engine question (feedback, shapes, allocation). A slow request, a late timer, a blocked thread is a host question (the event loop, I/O, the scheduler).
  3. What a "JavaScript runtime" is. Node is V8 plus libuv plus bindings. Bun is JavaScriptCore plus Zig. Deno is V8 plus Rust. The engine is the smaller part of each; the host is where they differ.
run it
node -p "typeof setTimeout, typeof fetch, typeof document" gives function function undefined: Node is a host with timers and fetch and no DOM. node -p "Object.getOwnPropertyNames(globalThis).length" counts what this host put on the global. In a browser console the number is far larger. Same engine, different world.
ENGINE, HOST, EMBEDDING
what is JavaScript and what is around it
swipe the figure sideways, or tap expand for full screen
1/6
the engine
The engine. V8 is a C++ library. Given source text it parses, compiles, runs, and collects garbage. It knows about Object, Array, Promise, Map, the prototype chain, closures and the microtask (job) queue. It knows nothing about a document, a socket, or a file.
2

What an engine has to do, and why it is hard

A JavaScript engine has the same four jobs as any language runtime: parse, compile, execute, manage memory. What makes JavaScript's version hard is the language's dynamism, and every piece of machinery in the next twelve parts exists to make a dynamic language run as if it were static.

the constraints the language imposes
  1. No static types. a + b may be integer addition, floating-point addition, string concatenation, or a call to valueOf, and the engine finds out at runtime, every time, unless it can remember what happened last time. That memory is type feedback (part 2).
  2. Objects are dictionaries, in principle. Any property can be added to any object at any time. A naive implementation is a hash lookup per property access. V8 makes objects behave like C structs when they are used like C structs, via hidden classes (part 3).
  3. Everything is late-bound. A function call resolves its target at runtime; a property might be a getter; a prototype might change. Optimised code has to check its assumptions or be invalidated when they break (part 4).
  4. Allocation is constant. Every object literal, closure, array and string is a heap allocation, and most die young. The collector has to be fast at reclaiming short-lived garbage without pausing the UI thread (part 5).
  5. Startup matters. A page ships megabytes of script and expects first paint in a second. Parsing and compiling everything up front is not an option; hence lazy parsing and tiered compilation (parts 1 and 4).
  6. eval, with, and friends. Features that can change scope at runtime force the engine to keep slow paths available. Their presence in a function disables optimisations for that function and its enclosers.
the strategy every modern engine uses
  1. Start fast, cheaply. Compile to bytecode quickly; interpret it. Most code runs once.
  2. Observe. Record what types and shapes each operation sees.
  3. Speculate. For hot code, compile machine code that assumes the observed types, guarded by cheap checks.
  4. Recover. When a guard fails, fall back to the bytecode and try again later with better information.
worked numbers
the whole course in one sentence:

  the engine is a compiler that guesses; fast JavaScript is JavaScript whose guesses stay true.

  monomorphic property access   → the guess "this is always shape A" holds
  consistent number types       → the guess "this is always a small integer" holds
  stable prototypes             → the guess "this method is always that function" holds
  no eval, no arguments leaks    → the guesses are allowed at all
3

The V8 pipeline at ten thousand feet

V8's pipeline has five stages and four tiers. Source goes through a scanner and parser to an AST; the AST is compiled to Ignition bytecode and interpreted; hot bytecode is compiled by Sparkplug, Maglev and TurboFan to successively better machine code; and any tier can deoptimise back to the interpreter. Each later part is one box of this figure opened up.

StageInput → outputWhenCostPart
Scanner + parserSource text → AST, scopes resolvedOn load (top level); on first call (inner functions, lazily)~1 MB/s per core... roughly 1 ms per 10 to 20 KB of minified JSP1
IgnitionAST → bytecode; interprets itFirst call of every functionFast to compile; 10 to 100× slower than optimised code to runP2
FeedbackObservations per bytecode → feedback vectorContinuously while interpreting and in baseline codeA store per accessP2, P3
SparkplugBytecode → machine code, 1:1After a handful of callsMicroseconds; removes dispatch, keeps every checkP4
MaglevBytecode + feedback → optimised codeWarm functions (hundreds of calls)~10× cheaper than TurboFan; most of the speedupP4
TurboFanBytecode + feedback → best codeHot functions (thousands of calls or long loops via OSR)Milliseconds; the fastest codeP4
DeoptimiserOptimised frame → interpreter frameWhen a guard failsMicroseconds plus lost optimisationP4
Orinoco (GC)Reclaims unreachable objectsWhen the young generation fills (often) or the old one grows (rarely)Sub-millisecond minor GCs; incremental, concurrent major GCsP5
the three facts to carry
  1. Bytecode is the canonical form. Machine code from any tier is a cache that can be thrown away; the bytecode is kept for the function's lifetime (and even it can be flushed for cold functions and regenerated).
  2. Feedback drives everything above the interpreter. No feedback, no optimisation. Bad (inconsistent) feedback, worse optimisation. The feedback vector is the most important data structure in the engine for you as a programmer.
  3. Tiers exist because compile time is real time. Spending 5 ms optimising a function that runs for 1 ms is a loss. The tiers are a bet-sizing strategy: cheap compile for most code, expensive compile for the little that matters.
run it
node --trace-opt --trace-deopt script.js prints a line each time a function is marked for optimisation, optimised, or deoptimised, with the tier and the reason. Run it on any script with a hot loop; the sequence is the figure above happening in front of you.
THE V8 PIPELINE
from source text to machine code, in tiers
swipe the figure sideways, or tap expand for full screen
1/7
parse
Source text arrives. The scanner turns characters into tokens; the parser turns tokens into an abstract syntax tree, resolving scopes as it goes. Functions not called yet are pre-parsed only: syntax checked, not compiled. Part 1.
4

Tiering: one function's life

Follow one function through ten thousand calls. The pattern is the same for every function the engine ever sees; only the thresholds vary.

the thresholds (approximate, and tuned per release)
  1. Lazy compile on first call. Until then the function body was pre-parsed: scanned for syntax errors and scope information, no bytecode. The first call pays for full parsing and bytecode generation.
  2. Interpretation with feedback. The feedback vector is allocated lazily too, after the function has been called a few times, so that run-once functions do not pay for it.
  3. Sparkplug after a small invocation budget. Compiles the bytecode straight to machine code; no feedback needed. Also compiled in batches on a background thread in some configurations.
  4. Maglev when the function is warm: an interrupt budget based on the bytecode size and the number of executions. Compiles concurrently on a background thread; the function keeps running in Sparkplug code meanwhile and switches at the next call.
  5. TurboFan when hot. Also concurrent. The budget is larger, and a long-running loop can trigger on-stack replacement: the running frame is swapped for optimised code mid-loop.
  6. Deoptimisation at any point after a tier-up, on a failed assumption. The feedback is updated; the function may be re-optimised. After enough deopts (a per-function counter), the engine stops trying and leaves it in the baseline tier.
what follows for how you write code
  1. Startup code should be small and run once. It is paid in parse and compile and never reaches a tier that pays back. Big top-level bundles are expensive because of this, not because of the loop inside them.
  2. Hot code should see the same types every time. The payoff of tiers 3 and 4 depends on feedback being monomorphic. A hot function that sees five different object shapes compiles to slower code than one that sees one.
  3. Do not warm up for the wrong reason. Calling a function a thousand times in a benchmark "to warm it up" moves it to TurboFan; in production it may live in Ignition. Measure the tier you will run in (part 14).
  4. Deopt loops are the pathology to watch. A function that optimises, deopts, re-optimises, deopts again is paying compile cost repeatedly. --trace-deopt shows it; the fix is in the feedback (part 4).
worked numbers
a small experiment:

  function add(a, b) { return a + b }
  for (let i = 0; i < 1e5; i++) add(i, 1);         // integers: optimised for Smi add
  add('x', 'y');                                        // strings: the guess fails

  $ node --trace-opt --trace-deopt add.js
  [marking 0x... <JSFunction add> for optimization to MAGLEV, reason: hot and stable]
  [compiling method 0x... <JSFunction add> (target MAGLEV)]
  [marking ... for optimization to TURBOFAN, reason: hot and stable]
  [bailout (kind: deopt-eager, reason: not a Smi): begin ... <JSFunction add>]

"reason: not a Smi" is the engine telling you which guess you broke.
ONE FUNCTION'S LIFE
what the engine does as a function gets called
swipe the figure sideways, or tap expand for full screen
1/7
call 1
Call 1. The function is lazily compiled to bytecode on first call (it was only pre-parsed before). Ignition interprets it. Each property access in it records what it saw into the feedback vector.
5

Where startup time goes

For a page or a server process, the engine's cost before any of your code runs is parse plus compile plus the top-level execution of every script. This chapter is the accounting; part 1 is the parser itself.

the budget for 1 MB of minified JavaScript on a mid-range phone
  1. Download and decompress: host cost, 300 KB over the wire at ~3:1 compression.
  2. Scan and pre-parse everything: ~100 to 200 ms. The scanner must see every byte; the pre-parser checks syntax and finds function boundaries without building ASTs for inner functions.
  3. Full parse and compile what runs at load: top-level code and every function called during initialisation. For a framework app that is a large fraction: module factories, component definitions, the first render. ~100 to 300 ms.
  4. Execute top level: whatever the bundle does on load. For a modern framework: build the component tree, hydrate. Frequently the largest item, and the only one entirely under your control.
  5. Code caching (browser host): after a first run, V8 serialises bytecode and the browser stores it with the HTTP cache entry; the next load skips steps 2 and 3 for cached functions. Node has v8.setFlagsFromString and the compile cache (module.enableCompileCache()) for the same effect on disk.
what moves the numbers
  1. Ship less. The only lever that reduces every step. Code splitting by route; tree shaking; dropping dependencies that are mostly unused.
  2. Defer what is not needed to render. A function that is not called at load is pre-parsed (cheap) and not compiled (expensive) until it is. Lazy import() moves it out of step 2 entirely.
  3. Avoid defeating lazy parsing. A function wrapped in parentheses, (function(){...})(), is a hint to compile eagerly (the PIFE heuristic). Bundlers use it on purpose for IIFEs that will run; a minifier that wraps everything that way makes everything eager.
  4. Streaming. Browsers parse script on a background thread as it downloads; by the time the last byte lands, most of step 2 is done. Inline scripts and eval cannot stream.
  5. Snapshots (Node, Deno, embedded V8): run the initialisation once at build time, serialise the heap, deserialise at startup. Node's own built-ins start from a snapshot; --build-snapshot lets an app do the same.
run it
In Chrome DevTools: Performance → record a reload → find "Compile Script" and "Compile Code" events under "Evaluate Script" for your main bundle. In Node: node --cpu-prof app.js and open the profile in DevTools; the top-level frames before your first function are the parse and compile cost. If "Compile" dominates "Evaluate", you are shipping code you do not run at load.
6

Isolates, contexts and heaps

V8's units of memory and execution are the isolate and the context. Every memory limit, every GC pause, every "JavaScript is single-threaded" statement is about an isolate. Every "the global object" and "instanceof fails across frames" is about a context.

isolate
  1. One heap, one thread at a time. An isolate holds all objects, compiled code and feedback for its scripts. Only one thread may be inside an isolate at a time (the embedder locks it); V8's own helper threads (GC, concurrent compilation) work alongside under the engine's control.
  2. Memory limits are per isolate. Node's --max-old-space-size sets the old generation limit for the main isolate; a worker_thread has its own, settable in its options. Chrome gives each renderer's main isolate a limit based on device memory.
  3. Crossing isolates means copying. Structured clone, transfer of ArrayBuffers, or SharedArrayBuffer (the one exception: shared memory between isolates, with Atomics).
  4. Hosts and isolates. Browser: one isolate per renderer main thread, one per worker. Node: one for the main thread, one per worker_thread. Cloudflare Workers: one per deployed script, many per process, which is what makes their cold start milliseconds instead of seconds: no process to spawn, only an isolate to create from a snapshot.
context
  1. A realm. A global object, a fresh set of built-ins (Object, Array, Promise...), and a global lexical scope. Creating one is cheap (built-ins come from a snapshot).
  2. Several per isolate. Same-origin iframes share the main isolate and are separate contexts; vm.createContext in Node makes one; a sandboxed new Function does not (it runs in the caller's context).
  3. Objects cross contexts freely within an isolate, which is why an array from an iframe is not instanceof Array in the parent but Array.isArray works: the object is reachable; its prototype is the other realm's.
  4. Context-dependent code. Optimised code may embed a context's constants (its Array.prototype, say). Calling the same function from another context can deopt or use a different code object.
handles and the embedder API
  1. The host holds V8 objects through handles, indirections the moving GC can update. Local handles live in a HandleScope and die with it; Persistent and Global handles outlive scopes and must be explicitly released.
  2. A native addon that forgets to release a Global handle has created a leak that no JavaScript code can fix, and that a heap snapshot shows as retained by "(Global handles)".
  3. Weak handles let the host be told when an object is collected; this is how FinalizationRegistry and DOM wrapper tracing are built.
run it
node -e "const vm=require('vm'); const c=vm.createContext({}); const a=vm.runInContext('[]', c); console.log(Array.isArray(a), a instanceof Array)" prints true false: one isolate, two contexts, two Arrays. node -e "console.log(require('v8').getHeapStatistics().heap_size_limit/1048576|0, 'MB')" prints this isolate's limit.
ISOLATES, CONTEXTS, HEAPS
the units of V8 memory and execution
swipe the figure sideways, or tap expand for full screen
1/6
isolate
An isolate owns a heap and runs on one thread at a time. Two isolates share nothing: no objects, no strings, no compiled code (except read-only shared pieces). This is the unit of memory accounting and of parallelism.
7

The host's loop and the engine's queue

The event loop is not in the engine. What the engine has is a job queue (what the HTML spec calls the microtask queue) and a rule: after any script or callback runs to completion, drain the queue before returning to the host. The host owns the outer loop: which task runs next, when rendering happens, how timers and I/O are delivered.

the division
  1. Engine: runs a script or a function the host asked it to run; collects promise reactions, queueMicrotask callbacks, and await continuations into the job queue; drains that queue completely (including jobs enqueued during draining) when the current execution context stack empties; returns.
  2. Host: picks the next task from its queues (timers, I/O completions, DOM events, message events, in a host-defined order); calls the engine; after the engine returns, does host things (rendering, in a browser; checking the poll phase, in Node); repeats.
  3. The guarantee that is the same everywhere: microtasks run after the current task and before the next task. Promise ordering, await ordering, and "a microtask that queues a microtask runs in the same drain" are identical in Chrome, Node, Deno and Bun because they are engine semantics.
  4. What differs per host: whether setTimeout(0) runs before or after a pending I/O callback (Node's phases); whether a DOM event handler's microtasks run between two listeners for the same event (yes, in a browser, when dispatched by the browser; no, when dispatched synchronously by script); when rendering happens; process.nextTick, which is Node's own queue, drained before the engine's microtasks.
worked numbers
one task, in a browser and in Node:

  setTimeout(() => log('timeout'), 0);
  Promise.resolve().then(() => log('micro 1'));
  queueMicrotask(() => log('micro 2'));
  (async () => { log('async start'); await null; log('after await') })();
  log('sync end');

  everywhere: sync end? no:  async start · sync end · micro 1 · micro 2 · after await · timeout

  Node only, if you add process.nextTick(() => log('tick')):  tick runs before micro 1. that queue is Node's, not V8's.
the pointer
The browser course's part 6 is the browser host's loop in full; the Node module's event loop part is libuv's. This course's part 10 is the engine's side: how a promise reaction becomes a job, and what await compiles to.
8

The tools: d8, node flags, and natives syntax

Every claim in this module can be checked. The engine ships its own shell and dozens of tracing flags; Node exposes them; DevTools visualises the results. Learn the five commands below and you can verify anything in the next fourteen parts.

CommandShowsUse it for
node --print-bytecode --print-bytecode-filter=fn script.jsIgnition bytecode for function fn: registers, the accumulator, feedback slot indicesPart 2: what your source became
node --trace-opt --trace-deopt script.jsEach tier-up with its reason; each deopt with its reason and bytecode offsetPart 4: is this hot function staying optimised, and if not, why
node --allow-natives-syntax -e "..."Enables % intrinsics: %DebugPrint(obj) (hidden class, elements kind, properties), %HaveSameMap(a, b), %OptimizeFunctionOnNextCall(f), %GetOptimizationStatus(f)Part 3: are these two objects the same shape; part 4: force a tier and inspect
node --trace-gc script.js (and --trace-gc-verbose)Each collection: type (Scavenge, Mark-Compact), heap before and after, pause timePart 5: how often, how long, which generation
node --cpu-prof script.js → .cpuprofile, open in DevTools PerformanceSampled stacks over time; a flame chart with (optimised) and (interpreted) annotations when --prof details are onPart 14: where time goes
node --heap-prof, or DevTools MemoryAllocation sampling; heap snapshots with retainersPart 5: who allocates, who retains
d8 (the V8 shell, built from source or via jsvu)The engine alone, no host: every flag, --print-opt-code, --trace-ic for inline cache transitionsWhen Node's host noise gets in the way; --trace-ic especially
node --v8-options | grep <word>Every V8 flag with its defaultFinding the flag for the thing you want to see
reading the %DebugPrint output, once
  1. map: the hidden class pointer, and its details: instance size, number of in-object properties, elements kind, whether it is stable, the back pointer to the previous map, the transition tree.
  2. elements: the backing store for indexed properties, with its kind: PACKED_SMI_ELEMENTS, HOLEY_DOUBLE_ELEMENTS, DICTIONARY_ELEMENTS. Part 6.
  3. properties: named properties beyond the in-object ones; empty, a fixed array, or a dictionary (the "slow mode" you want to avoid).
  4. prototype: the next object in the chain, printed recursively.
run it
node --allow-natives-syntax -e "const a={x:1,y:2}; const b={y:2,x:1}; console.log(%HaveSameMap(a,b)); %DebugPrint(a)". The answer is false, and the DebugPrint shows why: a's map was reached by adding x then y; b's by y then x. Two transition paths, two hidden classes. Part 3 is this fact and everything that follows from it.
9

The map of this course

the engine (parts 1 to 6)
  1. P1 Parsing: the scanner, the pre-parser and lazy compilation, the AST, scope analysis, and why function size and placement decide startup.
  2. P2 Ignition: the bytecode, the register file and accumulator, how a property load becomes LdaNamedProperty with a feedback slot, and reading --print-bytecode.
  3. P3 Hidden classes and inline caches: maps and transitions, in-object versus out-of-object properties, elements kinds, monomorphic to megamorphic, and the rules for keeping objects fast.
  4. P4 Optimisation: Sparkplug, Maglev, TurboFan, what speculation looks like in the generated code, on-stack replacement, every deopt reason you will meet and what to do about each.
  5. P5 Memory and GC: the generational heap, the scavenger, incremental and concurrent marking, write barriers, what a leak looks like in a snapshot, and what allocation costs.
  6. P6 Values: Smis and heap numbers, pointer compression, strings (sequential, cons, sliced, thin, internalised), ropes and flattening, symbols, BigInt.
the language, from the engine's side (parts 7 to 12)
  1. P7 Objects and prototypes: the chain, property descriptors, accessors, Proxy and Reflect, the metaobject protocol, and what each costs.
  2. P8 Functions and closures: scopes, context objects, what a closure captures and shares, this in all its bindings, arrow functions, bind, call, apply.
  3. P9 Iteration and generators: the iterator protocol, generators as state machines, async iteration, what for...of desugars to and why it is slower than an index loop (and when it is not).
  4. P10 Async internals: promises as objects, reactions as jobs, await desugared, the microtask ordering rules, async stack traces, unhandled rejections.
  5. P11 Modules: ESM's three phases, linking and the module record, cycles, top-level await, CommonJS interop in Node, dynamic import.
  6. P12 Collections and typed arrays: Map and Set internals (ordered hash tables), WeakMap, WeakRef and FinalizationRegistry, ArrayBuffer, DataView, typed arrays and their elements kinds.
using it (parts 13 and 14)
  1. P13 Design patterns, mechanism first: module, observer, pub/sub, state machine, command, strategy, factory, builder, proxy-based reactivity, memoisation: each as the engine feature it rides on, and the point where it becomes the wrong tool.
  2. P14 Performance in practice: benchmarking without being fooled by the optimiser, flame charts, allocation profiles, the hot-path checklist, and what the engine will never save you from.
how to read it
In order, once. Then parts 3, 4 and 5 again, with a terminal open, on your own code. Those three are where the money is.