Why your microbenchmark lies
The traps that make microbenchmarks report fantasy numbers: dead-code elimination, constant folding and hoisting, un-accounted JIT warm-up, OSR mid-measure, GC noise, console.log/try-catch deopts in the timed region, and micro-vs-macro mismatch
You wrote a loop to prove version A is faster than version B. The loop ran in 0.0ms. You ship version A. In production it is no faster — sometimes slower. The benchmark did not lie about timing; it measured a program the optimiser had quietly rewritten into nothing, because the result was never used. Almost every confident microbenchmark you have read is wrong in one of a handful of specific ways.
Why the JIT is your benchmark’s adversary
Before you write another benchmark loop, ask yourself: what does TurboFan do when it sees a computation whose result is never observed? It removes it — completely. That is the root of every trap below.
A microbenchmark is a tiny program whose output you ignore, run in a tight loop — which is exactly the program an optimising compiler is best at demolishing. TurboFan’s whole job is to remove work that does not affect observable behaviour. If your benchmark’s result is never observed, the work that produced it is, by definition, removable. The traps below are all consequences of the optimiser doing its job on code you did not intend it to optimise away.
Trap 1 — dead-code elimination (DCE)
If the loop body computes a value nothing reads, the optimiser proves the computation is observably useless and deletes it. Your “1 billion ops” benchmark becomes an empty loop — sometimes the loop itself is removed too. The fix is a black-hole sink: accumulate every result into a variable you read after the timed region (and ideally print or return), so the optimiser cannot prove the work is dead.
// LIES — result discarded, body eliminated
for (let i = 0; i < 1e9; i++) { expensive(i); }
// HONEST — sink forces the work to be observable
let sink = 0;
for (let i = 0; i < 1e9; i++) { sink += expensive(i); }
console.log(sink); // read it so DCE cannot remove the loopTrap 2 — constant folding and loop-invariant hoisting
If the input is a literal constant, the optimiser computes the answer once at compile time (constant folding) and your loop measures nothing. If a sub-expression does not change across iterations, it is hoisted out of the loop (loop-invariant code motion) and runs once instead of N times. The fix: make inputs vary unpredictably — derive them from the loop index or a pre-generated array of random values the optimiser cannot precompute (a “blackbox” input).
Trap 3 — un-accounted JIT warm-up
The first iterations run in Ignition (interpreted), then Sparkplug, then Maglev/TurboFan once the function is hot. If you time from iteration zero, you average cold interpretation, compilation pauses, and steady-state machine code into one meaningless number — and a “fast” result may just be a benchmark too short to ever reach TurboFan. Discard a warm-up phase, then measure; or report tiers separately if you care about both start-up and steady state.
Trap 4 — OSR mid-measurement
One giant for loop can trigger on-stack replacement: V8 compiles and swaps in optimised code while the loop is still running, so the early part of the very loop you are timing runs interpreted and the rest runs optimised — a discontinuity baked into a single sample. Prefer many calls to a smaller benchmark function (which warms up the function, not one loop) over one enormous loop.
Trap 5 — GC noise in the timed region
If the body allocates, the garbage collector will eventually pause inside your measurement, attributing GC time to your code as a random spike. Either remove allocation from the timed region, or run the benchmark many times and report a robust statistic (median/min) rather than the mean, so a stray GC pause does not dominate. Report distribution, not a single number.
Trap 6 — deopts you smuggled into the timed region
console.log inside the timed loop, a try/catch that V8 will not optimise across, or a polymorphic call introduced by the harness itself can deopt the very function you are measuring — so you measure the deoptimised version. Keep the timed region pure: no logging, no exceptions, one stable shape and type, and verify with --trace-deopt that the hot function is not bailing during measurement.
Trap 7 — micro-vs-macro mismatch
Even a perfect microbenchmark answers only “how fast is this isolated pattern in a synthetic loop”. Your app runs it interleaved with other work, with cold caches, real input distributions, and GC competition — where the micro winner can lose. Microbenchmarks pick between two implementations of a known hot spot; they do not tell you the hot spot matters. For end-to-end truth use macro suites (JetStream, Speedometer) or profile the real app. For honest micro numbers, use a harness built to defeat these traps: tinybench or mitata handle warm-up, sinks, and statistics for you.
- Consume results into
- a black-hole sink
- Inputs
- randomised / blackboxed
- Warm-up
- discard, then measure
- Timer
- performance.now() (sub-ms)
- Report
- median/min, not mean
- Verify hot fn
- --trace-deopt = no bail
Your benchmark loop over a pure function reports 0.0ms for a billion iterations. What happened?
You time from the very first iteration of a hot function. Why is the number misleading?
Order the steps to turn a naive loop into an honest microbenchmark.
- 1 Feed randomised / blackboxed inputs so nothing can be constant-folded
- 2 Accumulate each result into a black-hole sink read after timing
- 3 Run a warm-up phase and discard it so the function reaches steady tier
- 4 Measure many runs and report median/min, then verify no deopt with --trace-deopt
▸Edge cases
The cruel twist: a microbenchmark can be too favourable. In a tiny loop, one object shape and one type reach the hot function, so the IC is perfectly monomorphic and TurboFan inlines everything — a fast number. In production the same function sees five shapes, goes megamorphic, and never inlines. The micro number was honest about the micro conditions and dishonest about your app. This is why a passing microbenchmark is necessary but never sufficient: confirm with a macro run or a production profile.
- 01Explain dead-code elimination in a benchmark and the black-hole sink that defeats it.
- 02What are JIT warm-up and OSR, and how do they corrupt a from-zero measurement?
- 03Why is a passing microbenchmark necessary but not sufficient, and what do macro suites add?
A microbenchmark is the program an optimising JIT is best at demolishing, because its result is ignored and its loop is tight. Seven traps follow: dead-code elimination deletes a body whose result is unused (defeat with a black-hole sink read after timing); constant folding and loop-invariant hoisting precompute literal or unchanging inputs (defeat with randomised/blackboxed inputs); un-accounted warm-up blends Ignition, compilation pauses and TurboFan steady state into one number (discard a warm-up phase or report tiers separately); OSR swaps optimised code into a still-running giant loop (prefer many small calls); GC noise injects pauses into the timed region (isolate allocation, report median/min); console.log/try-catch/polymorphism smuggle a deopt into the measured function (keep the region pure, verify with —trace-deopt); and micro-vs-macro mismatch means even a perfect micro number does not reflect the app (confirm with JetStream/Speedometer or a production profile). For honest micro numbers use tinybench or mitata, which handle sinks, warm-up and statistics for you. Now when you see a 0ms benchmark result, you will not celebrate — you will reach for a black-hole sink and run it again.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.
Apply this
Put this lesson to work on a real build.