From Source Text to Running Code

Here is a small thing that should not happen if JavaScript were purely an interpreted language. Take a function that does real work, call it a few dozen times in a row, and time each call:

function hardWork() {
  let total = 0;
  for (let i = 0; i < 1e7; i++) {
    total += Math.sqrt(i) * 1.5;
  }
  return total;
}

for (let run = 0; run < 20; run++) {
  const t = performance.now();
  hardWork();
  console.log(run, (performance.now() - t).toFixed(1), "ms");
}

The exact numbers depend on your machine and your engine, so I will not quote any. But the shape is reliable: the first few runs are visibly slower, and somewhere around the middle the time drops and stays down. Run 15 finishes in a fraction of the time run 0 took. Same code, same input, same hardware.

Nothing in your program changed. The engine changed the code out from under you while it ran. That is the whole story of this chapter, and this lesson is the map: what actually happens between the text in your .js file and a function executing on the CPU.

Interpreted or compiled? Yes

People still call JavaScript “an interpreted language”. That has not been the full truth for well over a decade. A modern engine both interprets and compiles the same code, and it decides which to do based on how your program actually behaves as it runs.

We will use V8 as the reference engine throughout this part. V8 powers Chrome, Edge, and Node, so it is the one you hit most. Firefox (SpiderMonkey) and Safari (JavaScriptCore) use different names for the moving parts, but the design is the same in spirit, and we will come back to them at the end.

Here is the journey a function takes in V8, start to finish:

source.jsParserASTbytecodeIgnition (interpreter)runs your bytecode right awaymost functionslive their wholelife right hereSparkplugquick machine codeMaglevfast optimizerTurboFanpeak optimizerhotterhotterhotdeopt: a broken guess falls back to the interpreter
The V8 pipeline. Everything runs interpreted first, then the hot functions climb the tiers to faster machine code.

Read left to right, then bottom to top. Your source text is parsed into a tree, that tree is turned into bytecode, and an interpreter called Ignition starts running the bytecode immediately. That is why a page or a script feels responsive almost instantly: the engine does not wait to compile everything before the first line runs.

While Ignition runs, it watches. Functions that get called a lot, or loops that spin many times, are marked hot. Hot code gets handed to a series of compilers, each slower to run but producing faster code than the last: Sparkplug, then Maglev, then TurboFan. The result is native machine code for the parts of your program that actually matter.

You can walk a single function through the whole pipeline here. The sample is fixed and the output at each stage is canned (you cannot really see V8’s internal bytecode from a web page), but the sequence is faithful:

interactiveWalk one function through the pipeline

From text to bytecode

The front of the pipeline is the part every language with a parser shares. V8 reads your source, checks it for syntax errors, and builds an Abstract Syntax Tree (AST): a structured representation of your code as nested nodes. return n * n becomes a return node wrapping a multiply node wrapping two references to n. That tree is the shape the rest of the engine works from.

One detail matters for startup speed. V8 does not fully parse every function up front. Most functions are only pre-parsed: the engine does a quick scan to find where each function ends and to catch syntax errors, but it skips building the full tree until the function is actually called. This is lazy parsing, and it is a real win because a lot of the code a page loads never runs. The cost is a second, fuller parse the first time a lazily-parsed function does get called.

From the AST, V8’s BytecodeGenerator produces bytecode. Bytecode is not machine code. It is a compact, made-for-V8 instruction set that a small virtual machine knows how to execute. Think of it as a tidy, pre-digested version of your function that is quick to generate and cheap to start running. Here is roughly what the bytecode for square looks like, though the real thing is denser and this is illustrative, not literal V8 output:

; function square(n)
Ldar   n        ; load argument n into the accumulator
Mul    n        ; multiply it by n
Return          ; return the accumulator

Ignition, the interpreter, runs this bytecode directly. No further compilation is needed to get going, which is exactly the point: the path from “user hit enter” to “code is running” is short.

Why not just compile everything up front?

If compiled machine code is faster, a fair question is why V8 does not skip the interpreter and compile your whole program to native code before running it, the way a C compiler does. Three reasons, and they compound.

Compilation is not free. Turning code into good machine code takes real time and memory. If you compiled every function the moment you loaded it, your program would sit there doing nothing while the compiler worked, and your user would stare at a blank screen. For code that runs once, you would spend more time compiling it than you ever save.

Most code runs once, or never. A typical page loads a pile of JavaScript, and a large fraction of it is setup, feature checks, error handlers, and branches that never fire in a given session. Optimizing all of that is wasted effort. You want to spend your compile budget only where it pays back.

The engine does not know the types yet. This is the deep one. JavaScript has no type annotations that the engine can trust at compile time. A function add(a, b) might be called with two numbers, or two strings, or an object and undefined. The engine genuinely cannot produce great machine code for a + b until it has watched the function run and seen what a and b actually are. You only get that information at runtime.

So the tradeoff is between two costs: how long until your code starts running, and how fast it runs once warm. Interpreting wins the first, compiling wins the second. V8 refuses to pick one:

execution speedhow long the function has been runninginterpret only: never speeds upIgnitionSparkplugMaglevTurboFanV8 climbs while it runsboth start the instant the code loads
Interpreting is available instantly but stays slow. V8 starts there and climbs, reaching high speed only on the code that keeps running.

An ahead-of-time compiler would flip this picture: nothing runs at all until the compile finishes, then it runs fast from the first line. Great for a program that runs for hours. Wrong tradeoff for a web page you want interactive in milliseconds.

Why not just interpret, then?

The mirror question: if interpreting starts instantly, why bother compiling at all? Because interpreting is slow in a way that a tight loop cannot hide.

An interpreter runs a fetch-decode-execute loop. For every single bytecode instruction it reads the opcode, figures out what to do, does it, and moves on. That per-instruction overhead is fine when a function runs a handful of times. Run the body of a loop ten million times and that overhead is paid ten million times over, and it dominates. The classic profile of a slow JavaScript program is a small amount of code (a hot loop, a render function called every frame) running an enormous number of times.

That is precisely the code worth compiling. Compiled machine code has no decode loop: the CPU runs your instructions directly. So V8 aims its compilers at exactly the functions where the compile cost gets amortized across millions of executions, and leaves everything else in the cheap interpreter.

Climbing the tiers

“Hot code gets compiled” is not one step, it is a ladder. Each tier is a different point on the compile-cost-versus-code-quality curve.

Ignition
interpreter
build cost
run speed
Sparkplug
baseline
build cost
run speed
Maglev
mid-tier optimizer
build cost
run speed
TurboFan
peak optimizer
build cost
run speed
Four tiers, four tradeoffs. Higher tiers cost more to produce but run faster, so V8 only pays for them where the code is hot enough to earn it back.

Sparkplug is a baseline compiler. It walks the bytecode and emits machine code with almost no cleverness: the compiler itself has been described as a switch statement inside a for loop. It does not analyze types or restructure anything. All it does is remove the interpreter’s decode loop, and that alone is a solid speedup for very little compile time. It is the cheap first rung.

Maglev is the mid-tier, added to close a gap. It is a real optimizing compiler: it builds a proper intermediate representation and uses what the interpreter observed to specialize the code. But it is tuned to be fast to compile, roughly an order of magnitude slower than Sparkplug while producing much better code, and roughly an order of magnitude faster than the top tier. Good code, quickly.

TurboFan is the peak. It does the heavy, aggressive optimization: inlining, eliminating redundant checks, escape analysis, the works. It produces the fastest code V8 can generate, and it is by far the most expensive to run, so only the genuinely hot, stable functions ever reach it. It compiles on a background thread so it does not block your program (more on that shortly).

Not every function visits every tier. A function that is warm might get Sparkplugged and stop there. Only the hottest, most stable code earns a trip all the way to the top:

fewer functions, hotter codeIgnitionevery function starts hereSparkplugthe warm onesMaglevthe hot onesTurboFanthe hottest few
Every function starts in the interpreter. Progressively fewer, hotter functions are worth promoting, so the population narrows sharply toward the top.

The optimizer is a gambler

Here is where JavaScript’s compilation gets genuinely different from a static language, and where the runtime information from earlier pays off.

The optimizing tiers do not compile your code in general. They compile it for the specific types they saw while the interpreter was running. As Ignition executes a function, it records what actually flows through it into per-function scratchpads called feedback vectors: this call site always received numbers, that property access always saw the same kind of object. This is the runtime information you cannot get any other way.

Ignition runs add(a, b)add(2, 3) returns 5add(10, 4) returns 14a and b keep being numbersfeedback: number, numberfeedbackOptimizerMaglev / TurboFanassume both are numbersemit a direct integer addskip the type checksoptimizedmachine codenow someone calls add of two strings: the guess broke, deopt back to Ignition
Ignition gathers type feedback as it runs. The optimizer uses it to emit a fast path built on an assumption, and bails out if that assumption is ever violated.

So the optimizer takes a bet. It sees that add has only ever been handed numbers, and it emits machine code that assumes numbers: a single integer addition with no type checks, no string-concatenation branch, none of the generality the language technically requires. That code is small and blazing.

Then someone calls add("foo", "bar"). The assumption is now false. The optimized code cannot handle it, so V8 deoptimizes: it throws away the fast version, rewinds to the safe bytecode, and resumes in Ignition, which handles every case correctly. The function keeps working. It just got slow again, and it may re-optimize later if things settle down.

This is called speculative optimization, and it is the engine of modern JavaScript performance. Speculate on the common case, keep the interpreter as a correct fallback, and pay a penalty only when a guess is wrong. Everything you will read in later lessons about object shapes and inline caches is really about giving the optimizer bets it can win.

// The optimizer loves this: `point` always has the same shape,
// so `.x` and `.y` are always in the same place.
function dist(point) {
  return Math.sqrt(point.x * point.x + point.y * point.y);
}

// Called a million times with { x, y } objects -> stays fast.
// This punishes it: the callback is handed wildly different
// shapes, so no single assumption holds and it stays generic.
function totalOf(items, read) {
  let sum = 0;
  for (const it of items) sum += read(it);
  return sum;
}
// read() sees numbers, then strings, then objects... no good bet.

You do not need to hand-tune for this. The takeaway is simpler: code that does a consistent thing with consistent shapes gives the optimizer a bet it can keep. Code that is wildly polymorphic on a hot path keeps forcing it back to the generic version. We will make “shape” precise in the next chapter.

It runs while you run

One more piece that makes the whole scheme practical: the expensive compilers do not run on your main thread. Maglev and TurboFan compile functions on background threads while your code keeps executing on the main thread in the interpreter or in Sparkplug code. When an optimized version is ready, V8 swaps it in for the next call. That is why the warm-up in the opening was gradual rather than a hard stall: your loop kept running the whole time, and faster versions quietly replaced slower ones underneath it.

If you have read the event loop, this is a different kind of background work: not tasks you scheduled, but the engine’s own compilation, invisible to your code. And all of this happens alongside the garbage collector, which reclaims memory on its own schedule. The engine is doing several jobs at once so that yours appears to be doing just one.

Other engines, same idea

V8 is the reference here, but the pattern is industry-wide. The names differ; the strategy does not.

What this means for your code

Almost nothing, and that is the honest answer. You do not write differently for Ignition than for TurboFan. Chasing tier behavior with clever tricks is a great way to write ugly code that a future engine version makes pointless. A few things are worth actually carrying with you:

  • Write clear, predictable code and the optimizer rewards you for free. Functions that do one consistent job with consistent data types are exactly what speculative optimization thrives on. You get the fast path by being boring, not by being clever.
  • Startup cost is real, and it is parse time. The one lever you genuinely control is how much JavaScript the engine has to parse before anything runs. Shipping less code, and splitting it so the browser only parses what a page needs, is a real win. This is where Core Web Vitals and bundle size meet the engine.
  • Do not trust a benchmark you did not warm up. The same code is slow cold and fast warm. Measure the state you actually ship.
  • Do not cargo-cult. “Never use try/catch in hot code”, “always monomorphize”, and similar folklore are often stale or were never true. When performance matters, profile the real thing in the real engine. Otherwise, write for humans.

The rest of this part zooms into the boxes in that first diagram. Next we open up how objects and memory are actually laid out, because that is what the optimizer is really betting on. After that, strings, numbers, and the garbage collector’s own machinery. This lesson is the frame; everything else slots into it.

Summary

  • JavaScript is both interpreted and compiled. In V8 your source is parsed to an AST, compiled to bytecode, and run immediately by the Ignition interpreter, so startup is cheap.
  • Hot functions tier up through Sparkplug (baseline), Maglev (fast optimizer), and TurboFan (peak optimizer). Each costs more to produce and runs faster, so V8 only pays for the tiers that hot code earns back.
  • The core tradeoff is startup latency versus peak speed. Interpreting starts instantly but stays slow; compiling costs upfront but runs fast. V8 starts interpreted and optimizes the hot paths later.
  • Optimizing compilers use type feedback gathered while interpreting to make speculative bets (assume these are numbers, assume this shape). A broken bet triggers a deopt back to the safe interpreter.
  • The heavy compilers run on background threads, so your code keeps running while faster versions are swapped in.
  • The exact tier names and thresholds are engine-specific and change. SpiderMonkey and JavaScriptCore use different names for the same idea. Learn the shape, not the constants.

What this means for your code: write clean, predictable functions and let the engine do its job. The only startup lever you really hold is shipping and parsing less JavaScript.