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.
What JavaScript is, and what it is not
"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.
- Engine (ECMAScript): parsing and evaluation;
Object,Function,Array,String,Promise,Map,Symbol,Proxy,Reflect; the prototype chain; closures and scope;async/awaitand the microtask (job) queue; modules' linking and evaluation; garbage collection as an invisible service. - 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". - Shared conventions, host-defined:
globalThisexists in the spec; what is on it is the host's.consoleis standardised separately by WHATWG.setTimeoutis HTML spec; Node reimplements it with different clamping.
- 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 orfetchcaching is true for one host. - 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).
- 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.
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.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.
- No static types.
a + bmay be integer addition, floating-point addition, string concatenation, or a call tovalueOf, and the engine finds out at runtime, every time, unless it can remember what happened last time. That memory is type feedback (part 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).
- 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).
- 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).
- 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).
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.
- Start fast, cheaply. Compile to bytecode quickly; interpret it. Most code runs once.
- Observe. Record what types and shapes each operation sees.
- Speculate. For hot code, compile machine code that assumes the observed types, guarded by cheap checks.
- Recover. When a guard fails, fall back to the bytecode and try again later with better information.
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
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.
| Stage | Input → output | When | Cost | Part |
|---|---|---|---|---|
| Scanner + parser | Source text → AST, scopes resolved | On load (top level); on first call (inner functions, lazily) | ~1 MB/s per core... roughly 1 ms per 10 to 20 KB of minified JS | P1 |
| Ignition | AST → bytecode; interprets it | First call of every function | Fast to compile; 10 to 100× slower than optimised code to run | P2 |
| Feedback | Observations per bytecode → feedback vector | Continuously while interpreting and in baseline code | A store per access | P2, P3 |
| Sparkplug | Bytecode → machine code, 1:1 | After a handful of calls | Microseconds; removes dispatch, keeps every check | P4 |
| Maglev | Bytecode + feedback → optimised code | Warm functions (hundreds of calls) | ~10× cheaper than TurboFan; most of the speedup | P4 |
| TurboFan | Bytecode + feedback → best code | Hot functions (thousands of calls or long loops via OSR) | Milliseconds; the fastest code | P4 |
| Deoptimiser | Optimised frame → interpreter frame | When a guard fails | Microseconds plus lost optimisation | P4 |
| Orinoco (GC) | Reclaims unreachable objects | When the young generation fills (often) or the old one grows (rarely) | Sub-millisecond minor GCs; incremental, concurrent major GCs | P5 |
- 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).
- 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.
- 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.
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.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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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).
- Deopt loops are the pathology to watch. A function that optimises, deopts, re-optimises, deopts again is paying compile cost repeatedly.
--trace-deoptshows it; the fix is in the feedback (part 4).
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.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.
- Download and decompress: host cost, 300 KB over the wire at ~3:1 compression.
- 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.
- 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.
- 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.
- 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.setFlagsFromStringand the compile cache (module.enableCompileCache()) for the same effect on disk.
- Ship less. The only lever that reduces every step. Code splitting by route; tree shaking; dropping dependencies that are mostly unused.
- 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. - 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. - 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
evalcannot stream. - 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-snapshotlets an app do the same.
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.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.
- 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.
- Memory limits are per isolate. Node's
--max-old-space-sizesets the old generation limit for the main isolate; aworker_threadhas its own, settable in its options. Chrome gives each renderer's main isolate a limit based on device memory. - Crossing isolates means copying. Structured clone, transfer of ArrayBuffers, or SharedArrayBuffer (the one exception: shared memory between isolates, with Atomics).
- 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.
- 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). - Several per isolate. Same-origin iframes share the main isolate and are separate contexts;
vm.createContextin Node makes one; a sandboxednew Functiondoes not (it runs in the caller's context). - Objects cross contexts freely within an isolate, which is why an array from an iframe is not
instanceof Arrayin the parent butArray.isArrayworks: the object is reachable; its prototype is the other realm's. - 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.
- The host holds V8 objects through handles, indirections the moving GC can update.
Localhandles live in aHandleScopeand die with it;PersistentandGlobalhandles outlive scopes and must be explicitly released. - 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)".
- Weak handles let the host be told when an object is collected; this is how
FinalizationRegistryand DOM wrapper tracing are built.
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.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.
- Engine: runs a script or a function the host asked it to run; collects promise reactions,
queueMicrotaskcallbacks, andawaitcontinuations into the job queue; drains that queue completely (including jobs enqueued during draining) when the current execution context stack empties; returns. - 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.
- The guarantee that is the same everywhere: microtasks run after the current task and before the next task. Promise ordering,
awaitordering, 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. - 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.
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.await compiles to.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.
| Command | Shows | Use it for |
|---|---|---|
node --print-bytecode --print-bytecode-filter=fn script.js | Ignition bytecode for function fn: registers, the accumulator, feedback slot indices | Part 2: what your source became |
node --trace-opt --trace-deopt script.js | Each tier-up with its reason; each deopt with its reason and bytecode offset | Part 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 time | Part 5: how often, how long, which generation |
node --cpu-prof script.js → .cpuprofile, open in DevTools Performance | Sampled stacks over time; a flame chart with (optimised) and (interpreted) annotations when --prof details are on | Part 14: where time goes |
node --heap-prof, or DevTools Memory | Allocation sampling; heap snapshots with retainers | Part 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 transitions | When Node's host noise gets in the way; --trace-ic especially |
node --v8-options | grep <word> | Every V8 flag with its default | Finding the flag for the thing you want to see |
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.elements: the backing store for indexed properties, with its kind: PACKED_SMI_ELEMENTS, HOLEY_DOUBLE_ELEMENTS, DICTIONARY_ELEMENTS. Part 6.properties: named properties beyond the in-object ones; empty, a fixed array, or a dictionary (the "slow mode" you want to avoid).prototype: the next object in the chain, printed recursively.
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.The map of this course
- P1 Parsing: the scanner, the pre-parser and lazy compilation, the AST, scope analysis, and why function size and placement decide startup.
- P2 Ignition: the bytecode, the register file and accumulator, how a property load becomes
LdaNamedPropertywith a feedback slot, and reading--print-bytecode. - 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.
- 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.
- 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.
- P6 Values: Smis and heap numbers, pointer compression, strings (sequential, cons, sliced, thin, internalised), ropes and flattening, symbols, BigInt.
- P7 Objects and prototypes: the chain, property descriptors, accessors, Proxy and Reflect, the metaobject protocol, and what each costs.
- P8 Functions and closures: scopes, context objects, what a closure captures and shares,
thisin all its bindings, arrow functions, bind, call, apply. - P9 Iteration and generators: the iterator protocol, generators as state machines, async iteration, what
for...ofdesugars to and why it is slower than an index loop (and when it is not). - P10 Async internals: promises as objects, reactions as jobs,
awaitdesugared, the microtask ordering rules, async stack traces, unhandled rejections. - P11 Modules: ESM's three phases, linking and the module record, cycles, top-level await, CommonJS interop in Node, dynamic import.
- P12 Collections and typed arrays: Map and Set internals (ordered hash tables), WeakMap, WeakRef and FinalizationRegistry, ArrayBuffer, DataView, typed arrays and their elements kinds.
- 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.
- 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.