The JavaScript Pipeline
A modern JavaScript engine parses lazily, executes bytecode immediately, watches what actually happens, and recompiles the hot parts into native code with the observed types baked in — then unbakes them when the observation turns out to have been wrong.
Why is my JavaScript slow for the first few hundred iterations and then suddenly fast?
A moving target by design. The same function exists at different moments as source text with a byte range, a lazily-produced AST, bytecode for a register or stack machine, and one or more native code objects specialised to the types that have actually been observed — with a side table mapping the native frame back to a bytecode position so the engine can abandon the native version mid-execution. Nothing here is a single representation of the program; the set of representations that currently exist for a given function *is* the state of the engine.
A speculative tier may assume anything it has observed, provided it emits a guard that checks the assumption before relying on it and can reconstruct a valid interpreter state if the guard fails. Concretely: specialising an addition to integers is legal only if a type check precedes it and a deoptimization path exists that rebuilds the bytecode frame — locals, stack and position — and resumes the lower tier from the exact point the native code reached. Without that reconstruction the optimization is not merely risky, it is unimplementable.
Key points
- Every mainstream engine follows the same shape: parse lazily, execute bytecode immediately, profile, then recompile hot functions with the observed types assumed and guarded.
- Tier names and counts differ per engine and per release; the shape does not. Learn the shape and look up the names.
- Pre-parsing means bundle size is a startup cost before any of your code runs, independent of what that code does.
- Every speculative optimization requires a guard plus a side table that can rebuild an interpreter frame — otherwise the speculation is not implementable.
- A performance cliff is usually a deoptimization: an assumption that held for a long time stopped holding, and the engine threw away the specialised code.
- None of this happens at build time. The compiler's budget is microseconds, in the same process, competing with the program itself.
The shape, before any engine names
Every mainstream JavaScript engine follows the same shape, and the shape is worth learning independently of any product. Source arrives, often megabytes of it, and the engine must start executing quickly — so it does the minimum parsing needed, generates bytecode, and starts interpreting. While interpreting it counts: how often each function runs, which types flowed through each operation, which object shapes each property access saw. When a function crosses a threshold, an optimizing compiler recompiles it using those observations as assumptions, guarded by runtime checks. If a guard fails, execution falls back.
That loop — execute, observe, specialise, guard, fall back — is the whole answer to why JavaScript performance has a warmup curve, why microbenchmarks lie, and why a small change to a hot function can cost a factor of ten. The individual lessons are [[tiered-compilation]], [[profiling-and-hotness]], [[speculative-optimization]], [[guards]], [[deoptimization]] and [[inline-caches]]; this lesson is where they are assembled into one route.
What differs between engines is the number of tiers, their names, the thresholds, and whether the bytecode machine is stack- or register-based. What does not differ is that there are tiers at all, that the first one starts fast and runs slowly, and that the last one is fast only because it assumed something it had to check.
- Source textload timeUTF-16 source, often delivered over a network.Nothing yet — but note that download and decompression are already on the critical path in a browser.
- Pre-parseload timeA skeleton: function boundaries, scope shapes, and syntax errors.Enough information to know where every function starts and ends, and whether the file is syntactically valid, without building a full tree for bodies that may never run.Nothing permanently — but a function that is later called must be parsed again, so a pre-parse that guesses wrong is paid for twice.
- Full parse (lazy, per function)run timeAn AST for one function body, built on first call.The structure the bytecode generator needs.Usually discarded immediately after bytecode generation; the tree is not kept around.
- Bytecoderun timeInstructions for the engine's own virtual machine, plus feedback slots.Something executable within milliseconds of the source arriving, and a place to record observations — one feedback slot per operation that could specialise.Source-level structure. Stack traces now come from position tables.
- Baseline execution + profilingrun timeThe same bytecode, interpreted or compiled with no assumptions, accumulating type feedback.Facts no static compiler could have: which types actually occurred, which object shapes each property access saw, which branches were never taken.
- Optimizing compilationrun timeNative machine code for one function, specialised to the observed types, with guards.Inlining across call sites the feedback proved monomorphic, unboxed arithmetic, eliminated property lookups.Generality. This code is valid only while its guards hold.
- Deoptimizationrun timeA reconstructed interpreter frame at the bytecode position the native code had reached.A way back. Without it none of the speculation above would be legal.All the work the optimized version had done for this function, plus the time to rebuild the frame.
Read it asThe when column is the whole lesson: apart from delivery, every stage happens at run time, in the same process, interleaved with the program's own execution. There is no build step in this pipeline at all — which is why the compiler's time budget is measured in microseconds and why it must be able to give up.
Three engines, the same shape, different names
Naming the instances is useful precisely because it demonstrates that the shape is not one vendor's design. Learn the shape; look up the tier names when you need to read an engine's log output.
The counts move. V8 shipped Ignition and TurboFan as a two-tier system, added Sparkplug as a fast non-optimizing compiler, then Maglev between the two — so a statement about "V8's tiers" needs a year attached. JavaScriptCore has run LLInt, Baseline, DFG and FTL for a long time. SpiderMonkey's optimizing tier was Ion and is now the Warp/Ion pipeline with a different way of consuming feedback.
| Role | V8 | SpiderMonkey | JavaScriptCore |
|---|---|---|---|
| Bytecode execution | Ignition (register machine) | Baseline Interpreter | LLInt (low-level interpreter) |
| Fast non-optimizing compiler | Sparkplug | Baseline JIT | Baseline JIT |
| Mid optimizing tier | Maglev | — (folded into Warp) | DFG |
| Top optimizing tier | TurboFan | Ion (via WarpBuilder) | FTL (uses B3/Air backend) |
| Feedback storage | Feedback vectors per function | Baseline inline-cache stubs, consumed by Warp | Value profiles and IC stubs |
Lazy parsing, and why file size is a runtime cost
A large bundle contains far more functions than any single page view calls. Parsing all of them eagerly would be wasted work on the critical path, so engines pre-parse: a fast scan that finds function boundaries and scope structure and reports syntax errors, without building a tree for bodies. A body is fully parsed on its first call.
This is why the shape of your code affects startup in ways that have nothing to do with algorithmic cost. A function immediately invoked is parsed twice unless the engine recognises the pattern. A closure that captures a variable forces the pre-parser to record scope information it would otherwise skip. Bundle size costs download, decompression and pre-parse time before any of your logic runs — which is the mechanical reason code splitting is a performance technique rather than a tidiness one.
It also means the tree is transient. Unlike Python, where the AST is a public artifact, a JavaScript engine's tree usually exists for the duration of bytecode generation and is then discarded. Tooling that needs a JavaScript AST — bundlers, linters, transpilers — builds its own with a separate parser, which is why the ecosystem has half a dozen of them.
Why a guard is the price of every assumption
The optimizing tier's power comes entirely from assuming. If every value that reached this addition was a small integer, emit an integer add rather than a call into the generic addition routine that must handle strings, objects with valueOf, and BigInt. If every object at this property access had the same hidden shape, emit a fixed offset load instead of a hash lookup.
Neither assumption is provable — JavaScript permits the next call to pass anything at all. So each is preceded by a check, and the check needs somewhere to go when it fails. That destination is a reconstructed interpreter frame, built from a side table the optimizing compiler emitted alongside the code, mapping native register and stack locations back to bytecode locals at every point a guard could fire. Writing and maintaining that table is a substantial part of what makes an optimizing JIT hard, and it is the compiler-side half of [[deoptimization]].
The observable consequence is a performance cliff. A function that has run monomorphically for a million iterations gets called once with a string, the guard fails, the optimized code is discarded, and the function returns to a lower tier — sometimes permanently, if the engine decides the site is polymorphic. Nothing in the source changed. This is why [[jit-costs]] is a lesson and why benchmark harnesses that warm up carelessly report numbers that no production workload will reproduce.
r = Add(a, b) ; generic: may call valueOf, may concatenate strings, may throw
guard IsSmallInt(a) guard IsSmallInt(b) r = IntAdd(a, b) ; overflow branches to the deopt path
Only if the guards precede every use of the specialised result, the engine holds a side table mapping this native program point back to a bytecode position with valid values for every live local, and the deopt path can materialise any object the optimizer had eliminated. The guard is not a performance detail; it is the entire justification.
If the same rewrite is applied on the strength of a type annotation or a comment rather than an observation with a check — for example, hand-writing the integer add because "this is always called with numbers". A single string argument then produces silent concatenation or a wrong numeric result rather than a deoptimization, which is the difference between a JIT and a miscompilation.
How it works
The steps, in the order the compiler takes them.
- Source is pre-parsed to find function boundaries, scope structure and syntax errors, without building bodies.
- On first call, a function body is fully parsed and lowered to bytecode with a feedback slot attached to every operation that could specialise.
- Bytecode executes in an interpreter or a fast baseline compiler, filling feedback slots with observed types and object shapes and incrementing invocation and loop-back-edge counters.
- When a counter crosses a threshold, the optimizing compiler builds its own IR from the bytecode plus the feedback, treating each observation as an assumption and emitting a guard for it.
- It then inlines through call sites the feedback showed monomorphic, unboxes arithmetic, hoists redundant checks, and emits native code plus a deoptimization side table describing where every live value lives at each guard.
- If a guard fails, the engine reads the side table, materialises a bytecode frame with the right locals and stack, and resumes in a lower tier at the corresponding bytecode position — possibly re-optimizing later with the new observation folded in.
- A loop already running when its function becomes hot is transferred mid-execution by on-stack replacement — see
[[on-stack-replacement]].
How it breaks
What the engineer observes when it goes wrong — not what goes wrong internally.
- A function is fast in every test and slow in production because production passes a second object shape, the call site goes polymorphic, and the inline cache degrades to a lookup — with no error and nothing in the source to point at.
- A benchmark reports a number ten times better than reality because the harness ran the same monomorphic input a million times and the engine specialised for it.
- A page is slow to become interactive despite fast code, because a multi-megabyte bundle must be downloaded, decompressed and pre-parsed before anything executes.
- Adding a
try/catch, anargumentsuse or a rarely taken deoptimizing construct to a hot function makes it an order of magnitude slower; the change looks harmless and the mechanism is invisible from the source. - A long-running server's memory grows because optimized code, feedback vectors and deopt tables are retained per function — the code is not leaking, the compilation artifacts are.
When it helps
- Explaining warmup: why the first hundred iterations of a loop are unrepresentative and why
[[benchmarking]]in this environment needs a warmup phase and a steady-state measurement. - Diagnosing a performance cliff with engine trace flags instead of guesswork.
- Deciding that consistent object shapes and monomorphic call sites are worth some code-style cost in genuinely hot paths — and only there.
- Understanding why bundle size, code splitting and lazy loading are runtime concerns rather than build tidiness.
When it hurts
- Writing "JIT-friendly" code everywhere. The engine handles ordinary code well; hand-tuning cold paths for shape stability costs readability and buys nothing measurable.
- Reasoning about a specific engine's tiers from a blog post. Thresholds and tier structure change between releases, and a technique tuned for one can be neutral or harmful in another.
- Assuming warmed-up numbers apply to short-lived processes. A serverless handler or a CLI may exit before the optimizing tier ever runs — see
[[startup-time-and-cold-start]].
What it costs
Every one of these is paid by something.
- Deferring compilation to run time buys type information no static compiler could have, and pays with warmup latency, compilation competing with the program for CPU, and memory for bytecode, feedback vectors, native code and deopt tables — all held simultaneously.
- Lazy parsing buys startup time and pays it back with interest for functions that are called after all, since their bodies are scanned twice; it also complicates every error message that must be produced before a body was parsed.
- Speculation buys near-native arithmetic on a dynamically typed language and pays with implementation complexity that is difficult to overstate: every optimization must be undoable, at every point, with a correct frame reconstruction.
- Multiple tiers buy a smooth curve from fast start to fast steady state, and pay with the engineering cost of keeping several compilers semantically identical — a discrepancy between tiers is a miscompilation that appears only under load.
What else you could do
What a different compiler or language does instead, and when that is better.
- Ahead-of-time compilation with static types, as in
[[cpp-pipeline]]or[[rust-pipeline]], gets the specialisation for free at build time and gives up adapting to what the program actually does. - A pure interpreter with no tiers, as CPython was for most of its life, is far simpler and starts instantly, at a large steady-state cost — see
[[python-pipeline]]. - Bytecode caching and snapshotting (V8 code cache, startup snapshots) cut parse and compile time on repeat loads without giving up the dynamic pipeline.
- Compiling another language to
[[webassembly]]sidesteps the whole speculative machinery for compute-heavy code: types are already known, so the engine can compile once, predictably, with no deoptimization.
See it for yourself
The flag, dump or tool that shows you this directly.
node --print-opt-code,--trace-optand--trace-deoptshow V8 choosing to optimize and, more usefully, why it gave up.--trace-icshows inline caches going polymorphic.node --allow-natives-syntaxplus%GetOptimizationStatus(fn)reports the exact tier a function is in — the only way to be certain rather than inferring from timings.- Chrome DevTools Performance panel attributes time to script parsing, compilation and execution separately, which is how you tell a parse problem from a code problem.
- Firefox:
about:configjavascript.options.*and the Warp/Ion spew flags in a debug build. Safari/JavaScriptCore:JSC_dumpDFGDisassembly=1and friends on thejscshell. - For the tiering behaviour itself, our four-language pipeline comparison at
/compilers/pipelineruns the same program through all four routes side by side.
Plausible wrong readings
Stated the way a confident engineer states them.
- "JavaScript is interpreted." Every mainstream engine compiles it — first to bytecode, then, for hot code, to native machine code with the observed types specialised in.
- "The JIT will optimize my code, so how I write it does not matter." The optimizing tier assumes what it observed. Code that makes observations unstable — inconsistent object shapes, megamorphic call sites — prevents the assumptions from being made at all.
- "My function got slower, so V8 must have a bug." Far more often a guard started failing. Trace deoptimization before suspecting the engine.
- "Tier names like TurboFan are how JavaScript works." They are one engine's names for one version's tiers. The shape transfers; the names do not.
- "Warmup only matters for benchmarks." It decides whether a serverless handler that runs for 40ms ever reaches an optimizing tier at all.
Misconceptions
The claim, and what is actually true.
[[typescript-pipeline]].Go deeper
The same idea at increasing depth. Stop wherever it stops being useful.
overview
A JavaScript engine starts running your code almost immediately by compiling it to a simple internal instruction set and interpreting that. While it runs, it watches which types show up. When a function has run enough times, it compiles that function to real machine code, assuming the types it saw will keep showing up, and inserts checks. If a check fails, it throws the fast version away and goes back. That is why code speeds up after a while and can suddenly slow down again.
practical
Two rules cover most of what an application engineer can act on. First, keep hot call sites monomorphic: objects built by the same constructor with the same properties in the same order let the engine use a fixed-offset load instead of a lookup. Second, measure with warmup and measure the shape of the curve, not one number — and when a function is unexpectedly slow, run with --trace-deopt before theorising. Everything else, including most "JIT-friendly" folklore, is either stale or unmeasurable.
advanced
The design tension worth understanding is between speculation depth and deoptimization cost. Deeper assumptions — inlining four levels through monomorphic sites, eliminating an object allocation entirely because it never escapes — produce much better code and much more expensive failure, because the deopt path must now materialise objects that do not exist in registers anywhere. Engines therefore invest heavily in *not* deoptimizing repeatedly: they track which guards have failed, refuse to re-speculate on a site that has burned them, and in some cases compile a version with the union of the observed types instead. The shape of that policy, not raw compiler quality, is what makes one engine faster than another on real applications.
How much this depends on
Nothing in this domain is true of every compiler. These say how much.
try/catch, arguments, delete, changing an object's shape after construction — has changed repeatedly as engines improved. Lists of "deopt killers" circulating online are usually several years stale; verify with --trace-deopt against the version you ship on.If you were asked this in an interview
- Walk me through what happens to a JavaScript function between page load and it running at full speed.
- Why does every speculative optimization need a deoptimization path, and what does that path have to reconstruct?
- A function is fast in a benchmark and slow in production. Name three mechanisms that could explain it.
Connections
- Programming Languages & Runtime Internals — Hidden classes, object shapes and the garbage collector that must trace optimized framesThe property-access specialisation described here depends entirely on the runtime's object representation, and the deoptimization tables are also GC roots. This lesson owns the compiler-side half — the guard and the side table — and the object model it assumes is owned there.
- Observability & Performance Engineering — Sampling profilers, warmup methodology and steady-state measurementEverything in this lesson is invisible without measurement, and measuring a tiered runtime correctly is a discipline of its own: a profile that mixes warmup and steady state attributes time to functions that no longer exist in that form.