What you'll learn
Quick Answer
Just-In-Time (JIT) compilation means a language runtime compiles your code to native machine instructions while the program is running, instead of before it starts (ahead-of-time) or never at all (pure interpretation). Modern runtimes like V8 (which powers Chrome and Node.js) start by interpreting code, watch which functions actually run often, and recompile only those 'hot' functions into optimized machine code. We measured this directly: the same JavaScript loop ran about 11 times faster with V8's JIT enabled than with it forced off, and a separate Java benchmark showed roughly 7 times faster with its JIT (HotSpot) enabled than with it disabled.
Where JIT Sits Between Compilers and Interpreters
There are three broad ways a language runtime can turn your source code into something a CPU can execute, and JIT compilation is specifically a hybrid of two of them. Ahead-of-time (AOT) compilation — what a traditional C compiler does — translates the entire program to native machine code once, before it ever runs, producing an executable file that the CPU runs directly with no translation step left at run time. Pure interpretation — the simplest model — reads and executes source code (or an intermediate bytecode form of it) line by line, every single time, redoing the work of figuring out what each instruction means on every single execution, which is simple to build but leaves a lot of performance on the table because the same analysis gets repeated endlessly for code that runs in a loop millions of times.
Just-In-Time compilation tries to get the best of both: start by running the code through a fast interpreter (so the program starts executing almost immediately, with no big upfront compile delay), but watch the running program and detect which specific pieces of code are actually running often enough — 'hot' code — to be worth the expense of compiling down to real native machine instructions. Once a function crosses that threshold, the runtime compiles it in the background and swaps in the compiled version, so future calls to that same function skip the interpreter and run as genuine machine code. Code that only runs once or twice never gets this treatment, because the cost of optimizing it would exceed any benefit — this is explicitly a bet: the runtime spends time and memory compiling a function now, betting that function will be called enough times afterward for the compiled version to pay that cost back.
This explains the practical behaviour every JIT-based runtime shares: a program is often slower in its first moments than a fully AOT-compiled equivalent would be (because the interpreter is doing relatively unoptimized work and the JIT compiler itself is busy analyzing and compiling in the background), but it can converge toward — and in some benchmarks exceed — AOT-compiled performance once the hot paths have been compiled and optimized using information that's only available at run time, like which actual object shapes and value types show up in practice. V8 (Chrome and Node.js's JavaScript engine) and Java's HotSpot virtual machine are the two most widely deployed JIT systems in the world, and both of them are used directly in this article's benchmarks below.
V8's Tiered Compilation Pipeline
V8 doesn't have one JIT compiler — it has a ladder of four distinct execution tiers, each trading compile speed against code quality differently, and a function moves up the ladder only as evidence accumulates that it's worth the next tier's cost. Ignition is a register-based bytecode interpreter: V8 parses JavaScript source into an abstract syntax tree, lowers that into bytecode, and Ignition starts executing that bytecode immediately — this is the tier every piece of JavaScript starts in, with essentially no compile delay.
Sparkplug is the first JIT tier: a genuinely non-optimizing compiler that does no analysis at all — it walks the bytecode once and emits machine code nearly one-to-one with it, for close to the cost of a plain memory copy. What that buys is the removal of Ignition's per-bytecode dispatch overhead (the small cost of figuring out 'what instruction is this and what do I do with it' on every single bytecode, repeated every time that bytecode runs) without paying for real optimization. Maglev sits between Sparkplug and the top tier: roughly 10x slower to compile than Sparkplug but producing code that runs meaningfully faster, giving V8 a mid-tier option that delays the far more expensive top-tier compilation while still getting real optimization benefit sooner. TurboFan is the top tier — V8's full optimizing compiler, producing the fastest machine code V8 can generate, used for functions that have proven themselves 'hot and stable' over enough executions to justify the heaviest possible compilation cost.
The phrase 'hot and stable' there isn't marketing language — it's the literal reason string V8 itself logs when deciding to promote a function. Running the benchmark function below with V8's internal --trace-opt flag turned on produces this real, unedited output:
[marking 0x...<JSFunction hotFunction> for optimization to TURBOFAN,
ConcurrencyMode::kConcurrent, reason: hot and stable]
[compiling method 0x...<JSFunction hotFunction> (target TURBOFAN) OSR,
mode: ConcurrencyMode::kConcurrent]
[completed optimizing 0x...<JSFunction hotFunction> (target TURBOFAN) OSR]That's V8 itself, caught in the act: it watched a loop calling the same function thousands of times, decided the function was both 'hot' (called often) and 'stable' (consistently receiving the same shapes and types of arguments, so optimized assumptions about them are safe to bake in), and compiled it to TurboFan machine code — concurrently, on a background thread, so the main thread wasn't blocked waiting for the optimizing compile to finish. 'OSR' in that log stands for On-Stack Replacement: V8 can swap a running loop over to the optimized version mid-execution, without waiting for the function to be called again from scratch.
The Real Benchmark: JIT On vs JIT Off
Timing warm-up batches within a single run turned out to be too noisy on the development machine used for this article — background load and garbage collection pauses swamped the signal in a batch-by-batch comparison. The clean, reproducible experiment instead compares V8 running normally against V8 forced into interpreter-only mode using a real engine flag, --jitless, which disables the entire JIT pipeline (Sparkplug, Maglev, and TurboFan all off, leaving only Ignition's interpreter):
function hotFunction(n) {
let sum = 0;
for (let i = 0; i < n; i++) {
sum += (i * 3) % 7;
}
return sum;
}
// Called 2,000 times per batch, 50,000 loop iterations each call, 6 batches| Mode | Avg time per batch (2,000 calls) |
|---|---|
| node --jitless (interpreter only) | ~4,568 ms |
| node (normal, full JIT pipeline) | ~405 ms |
That's roughly an 11.3x speedup with the JIT pipeline enabled versus forced-off, on Node.js v22.16.0 running V8 version 12.4.254.21, for the exact same JavaScript source code — the only variable that changed was whether V8 was allowed to compile the hot function to machine code at all. As a second, independently sourced data point, we ran the equivalent benchmark on an entirely different JIT — Java's HotSpot virtual machine (Java 21.0.1) — comparing its default mode against java -Xint, a real JVM flag that forces pure bytecode interpretation with HotSpot's C1 and C2 JIT compilers both disabled:
| Mode | Avg time per batch (2,000 calls) |
|---|---|
| java -Xint (interpreter only) | ~2,758 ms |
| java (normal, HotSpot JIT enabled) | ~396 ms |
Roughly a 7x speedup with HotSpot's JIT enabled versus disabled, on a structurally similar hot loop, in a completely different language and runtime from V8. Two independent engines, two independently measured gaps (11x and 7x) in the same direction, for the same reason: pure interpretation re-evaluates the meaning of the same bytecode on every single pass through a loop run millions of times, while a JIT-compiled version has already paid that analysis cost once and just runs native instructions afterward.
Deoptimization: the Catch Behind Optimistic Compiling
TurboFan's optimized code isn't just 'the same logic, compiled' — it's compiled under specific assumptions about what kinds of values and object shapes the function will keep seeing, assumptions gathered from watching the function actually execute (this is why the V8 log line above said 'stable', not just 'hot'). A function that's always been called with plain numbers might get machine code specialized for fast numeric arithmetic, skipping the general-purpose checks needed to handle, say, a string or an object showing up instead.
The catch: JavaScript is dynamically typed, so nothing stops a later call from passing a different type of value than every previous call did. When that happens, the optimized code's assumptions are violated, and TurboFan has to bail out — this is called a deoptimization (or 'deopt'). V8 throws away the optimized machine code for that function, falls back to the slower Ignition interpreter (or a lower tier) for it, and — if the function keeps getting called — starts the whole hot-and-stable observation process over again from there, potentially reoptimizing with broader assumptions that account for the new case it just saw.
This is not a rare edge case reserved for unusual code; it's a direct, predictable consequence of how dynamically typed languages and optimizing JITs interact, and it's the real, technical reason behind a piece of JavaScript performance advice that otherwise sounds like superstition: keep a given function's inputs 'monomorphic' — consistently the same shape and type — if you want it to stay optimized. A function that sometimes receives a plain object and sometimes an array, or sometimes an integer and sometimes a float boxed differently internally, can end up oscillating between optimized and deoptimized states, paying both the deopt cost and the recompilation cost repeatedly, which is often slower in practice than if the function had never been optimized at all.
Java's HotSpot JIT faces a related but differently shaped problem: its C2 optimizing compiler makes aggressive assumptions too (inlining a method call based on which actual class implements an interface at a given call site, for instance), and it has its own deoptimization machinery for when a previously unseen implementation shows up at a call site that had been optimized assuming only one. The underlying principle is the same across both engines: optimizing compilation for a dynamic or polymorphic language is inherently a bet made on incomplete information, and the mechanism that handles a lost bet — safely falling back, not crashing — is just as much a required part of the design as the optimization itself.
What This Means When You're Writing Code
The most immediate practical consequence is about benchmarking, and it trips up even experienced developers: timing a function's very first few calls in JavaScript or Java and concluding something about its 'real' performance is measuring the interpreter and the JIT's warm-up cost, not the steady-state speed the function will actually run at in a long-lived process. Serious benchmarking tools (Node's own benchmarking guidance, Java's JMH — the Java Microbenchmark Harness) exist specifically to run enough warm-up iterations to let the JIT reach its stable compiled state before any measurement that's reported actually counts, because skipping that step reliably produces misleading numbers.
It also explains a pattern that surprises people coming from AOT-compiled languages: a short-lived script or CLI tool, run once and exiting quickly, may never get the benefit of a JIT's top-tier optimization at all, because it simply doesn't run long enough or call anything often enough to cross the 'hot' threshold — most of its execution time is spent in the cheaper, less-optimized tiers by design, since promoting everything to the most expensive tier immediately would make startup slower for no benefit in a program that exits before that investment pays off. This is a real, deliberate trade-off, not an oversight: a tiered JIT is optimized for the common case of code that runs repeatedly, at the cost of always paying full optimization cost for code that runs once.
For day-to-day coding, the practical guidance that follows directly from how TurboFan and HotSpot's C2 actually behave: keep hot functions receiving consistent types and shapes of arguments where you reasonably can, since that's what 'stable' in the V8 log output literally means and what keeps a function eligible for — and resident in — the fastest compiled tier rather than bouncing between deopt and reoptimization. And treat 'my code feels slow in a quick manual test' with suspicion until you've actually let it run long enough to warm up, because a surprising amount of perceived slowness in JIT-compiled languages is really just measuring the interpreter tier, not the compiled one your production traffic will actually spend most of its time running in.
Frequently Asked Questions
Is JIT compilation the same thing as ahead-of-time compilation, just delayed?
No — AOT compilation happens once, before the program runs, producing a fixed executable. JIT compilation happens repeatedly, during execution, and crucially uses information only available at run time (which functions are actually called often, what types of values they actually receive) to decide what to optimize and how. A JIT can also deoptimize and recompile the same function multiple times during one run, which an AOT compiler has no equivalent of.
Why was JIT-disabled JavaScript so much slower in the benchmark — isn't an interpreter supposed to be reasonably fast?
Ignition, V8's interpreter, is reasonably fast as interpreters go, but it still re-dispatches and re-evaluates each bytecode instruction every single time it's executed. In a tight loop run tens of thousands of times, that per-iteration dispatch overhead adds up, whereas JIT-compiled machine code has already paid that cost once and just executes native CPU instructions afterward — which is exactly the roughly 11x gap measured.
What is deoptimization and should I worry about it in my own code?
Deoptimization is when a JIT throws away optimized machine code for a function because a runtime assumption it was compiled under (usually about argument types or object shapes) turned out to be wrong, falling back to a slower tier. You don't need to obsess over it, but if a hot function in a performance-sensitive path receives wildly varying argument types, it's worth knowing that's the specific mechanism that can quietly keep it from ever reaching peak speed.
Does PyPy use the same kind of JIT as V8?
Not quite the same design, though the goal is similar. PyPy uses a tracing JIT: it watches for hot loops, records a linear trace of the actual operations a loop performs, optimizes that trace (removing redundant checks, inlining calls), and compiles the trace to native code, so later passes through that same loop skip the interpreter entirely. V8's tiered approach compiles whole functions through a ladder of increasingly optimizing compilers rather than tracing individual loop paths — different strategy, same underlying bet that hot code is worth compiling.
If JIT is so much faster, why does any runtime still interpret code at all?
Because compiling everything to optimized machine code immediately would make startup slow and would waste effort compiling code that only runs once or twice and never recoups that cost. Starting in a cheap interpreter and only promoting code that proves itself 'hot' is what lets a JIT-based runtime start fast and still reach near-peak speed for the code that actually matters.
