Benchmarking Traps and Dead-Code Elimination
Here is a benchmark. It times a million square roots, and it swears they take zero milliseconds.
const t0 = Date.now();
for (let i = 0; i < 1_000_000; i++) {
Math.sqrt(i);
}
console.log(Date.now() - t0, "ms"); // 0 ms
A million square roots in no measurable time. Either your laptop just outran physics, or the benchmark is lying. It is lying. Nothing in that loop ever computed a square root, because nothing ever looked at the answer, and a computation whose result is never observed is one the engine is entitled to delete. You did not measure Math.sqrt. You measured an empty loop, and then the engine deleted that too.
Almost every hand-rolled “X is faster than Y” snippet you have seen is wrong in some version of this way. That is the good news, actually. After the pipeline chapter you have everything you need to see exactly how, because each classic trap is one of the internals you already learned, turning up where you did not expect it. This lesson walks the traps one at a time, then shows you how to take a measurement you can trust.
The benchmark measured nothing
Start with the square-root loop, because it is the sharpest example. The optimising tier, TurboFan, does not translate your code line by line. It reasons about what your code does, and one of the oldest, most standard things any optimising compiler does is throw away work with no observable effect. That is called dead-code elimination, and it is not a bug. It is the compiler doing its job well.
Trace it the way the engine does. The loop calls Math.sqrt(i) and discards the return value. Math.sqrt has no side effects, so calling it and dropping the result changes nothing an outside observer could detect. The engine proves that, deletes the call, and now the loop body is empty. An empty counting loop also has no observable effect, so that goes too. What is left is a Date.now(), a Date.now(), and a subtraction. Zero milliseconds is the honest answer to the question you accidentally asked.
The fix is to make the result observable, so the engine cannot prove the work is pointless. Accumulate every value into a variable that outlives the loop, then do something with that variable the optimiser cannot see through:
let sink = 0;
const t0 = performance.now();
for (let i = 0; i < 1_000_000; i++) {
sink += Math.sqrt(i);
}
const ms = performance.now() - t0;
// force sink to be "used" without printing a million numbers
if (sink === -1) console.log("unreachable", sink);
console.log(ms.toFixed(3), "ms");
Now sink feeds a branch, the branch could run, and to know whether it runs the engine has to actually have sink, which means it has to run every add, which means it has to compute every square root. The work is anchored to something observable. This anchor has a few names in the wild: a sink, a blackhole, a do-not-optimise-away helper. The idea is always the same. Give the result somewhere real to go.
The answer was a constant all along
Dead-code elimination deletes work whose result is unused. Its close cousin deletes work whose inputs are already known. If everything an expression depends on is a compile-time constant, the engine can compute the answer once, at compile time, and drop the arithmetic entirely. This is constant folding, and it quietly guts benchmarks that feed a fixed value into the thing being measured.
// looks like a million square roots; is really one
let sink = 0;
for (let i = 0; i < 1_000_000; i++) {
sink += Math.sqrt(2); // 2 never changes
}
Math.sqrt(2) is the same number every iteration, and the engine knows it. Fold it to a constant, and the loop becomes sink += 1.4142… a million times, which folds again into a single multiply. You wanted the cost of a square root. You measured the cost of adding the same number to itself, which the engine also mostly deleted.
The defence is to vary the input so the engine cannot see the answer coming. Read your inputs from an array it cannot fold, or derive them from the loop counter:
// the engine cannot precompute sqrt of every distinct i
for (let i = 0; i < inputs.length; i++) {
sink += Math.sqrt(inputs[i]);
}
Varying inputs does more than block constant folding, and that turns out to be the deep reason microbenchmarks mislead even when you get everything else right. Hold that thought. It comes back near the end.
You timed the compiler, not the code
Suppose you dodge both deletions: you consume the result and you vary the inputs. Wrap Date.now() around the loop and you still get a number you cannot trust, because of when the loop ran versus when you started the clock.
A function does not run at one speed. It starts in the interpreter, Ignition, which is quick to start and slow per operation. As it keeps getting called, it climbs the tiers you met earlier: Sparkplug then Maglev, and finally TurboFan for the hottest code. Each rung is faster than the last. So the first hundreds or thousands of iterations run far slower than the steady state that follows, and a benchmark that averages over the whole run blends interpreted execution, three compilers doing their work, and the fast code, into one meaningless number. You measured the warm-up ramp, not the code.
The standard cure is a warm-up phase: run the code enough times to reach steady state, throw those timings away, then start measuring. Every serious benchmarking tool does this for you.
There is a nuance worth stating plainly, because it is where the “just warm it up” advice can itself mislead. Which number is the truth depends on how the code actually runs in your program. A tight function on a server hot path really does live in its steady, optimised state, so the warmed-up number is the honest one. But a function that runs a handful of times per page load, or once per request and never enough to fully tier up, spends its real life on the warm-up ramp. For that code, the fully optimised steady-state figure is a fantasy it never reaches, and quoting it oversells the code. Measure the temperature the code actually runs at.
Even a perfect loop measures a fantasy
Here is the trap that survives every fix so far, and it is the reason experienced engineers distrust microbenchmarks on principle. A microbenchmark runs your snippet in a sterile little world: one call site, one or two argument shapes, the same types every time. Your real program does not.
Remember inline caches. A call site that only ever sees one object shape stays monomorphic, and TurboFan specialises hard against it: direct field offsets, inlined methods, guards it can hoist out of the loop. Feed that same code the messy variety of a real workload, many shapes, mixed types, the occasional oddball, and the call site goes megamorphic. The engine falls back to a generic path that has to cope with anything. Same source code. Different machine code. Different speed.
This cuts both ways, which is what makes it nasty. A benchmark can flatter code by keeping it monomorphic, so your snippet looks fast in a way it never will inside a real app. And a benchmark harness can sabotage code from the inside. Loop over ten candidate functions through one shared call site to “compare them fairly” and that site sees ten shapes, goes megamorphic, and every candidate looks slow for a reason that has nothing to do with the candidates. Feed mixed types to be thorough and you can trip a deoptimisation that the real, type-stable workload would never hit. The harness is code too, and the engine optimises it with the same rules, including the ones working against you.
One number is its own kind of lie
Say you did everything right: consumed the result, varied the inputs, warmed up, kept the workload realistic. Run it once and you get 4.1 ms. Run it again: 5.7 ms. Again: 4.0. A single measurement is a sample from a noisy distribution, and the noise comes from the whole machine, not your code.
The garbage collector can pause your program mid-sample to do a collection, and that pause lands entirely inside whichever measurement was unlucky enough to be running. The CPU changes speed underneath you: turbo boost when it is cool and idle, throttling when it heats up or the laptop is on battery, and on a multi-core machine the run can hop to a different core at a different frequency. Other tabs and processes steal time. And if the thing you are timing is very short, you run into the clock itself: browsers deliberately coarsen performance.now() to blunt timing attacks, clamping it to roughly 100 microseconds in Chrome and about a millisecond in Safari, so timing a single nanosecond-scale operation just measures the resolution of the timer, not the operation.
So you do not want one number. You want many samples and a look at their shape: the median (or another percentile) as the typical case, and the spread as your confidence in it. If you have read observability, this is the same reason you page on p99 latency rather than an average. A mean is dragged around by the slow tail; the median and percentiles tell you what actually happens most of the time and how bad the unlucky cases get. Two snippets whose ranges overlap heavily are, for all you can prove, the same speed.
Timer resolution has a practical consequence too: never time a single fast operation. Run it in a batch of many thousands per sample, so the total is comfortably larger than the clock’s granularity, then divide. The tools do this by choosing a batch size automatically.
What an honest measurement looks like
Put the traps together and the difference between a benchmark that lies and one you can trust is stark. It is not about writing a cleverer timing loop. It is about handing the job to something that already knows these traps and stops fighting the optimiser by hand.
- 1 Date.now: about 1 ms granularity, coarser than the work
- 2 no warm-up: this times the interpreter
- 3 constant input: folded to one value
- 4 result unused: dead-code eliminated
- 5 one run: no distribution, pure noise
- ✓ a benchmarking library handles warm-up and statistics
- ✓ inputs varied so nothing constant-folds
- ✓ the result is consumed so nothing is deleted
- ✓ hundreds of samples; report the median and spread
- ✓ first question: does this line even matter?
In practice that means reaching for a real benchmarking library rather than Date.now() around a for loop. The current crop for JavaScript, tools like tinybench and mitata, run the warm-up, choose a batch size against the timer resolution, take many samples, guard the result from elimination, and report a distribution with a standard deviation instead of a lone number. Some go further and detect when the engine deoptimised mid-run so you know a sample is contaminated. You will still make mistakes with them, but not the four in the box above.
The demo below runs the same benchmark two ways so you can watch the traps in action. The naive method wraps a timer around a loop whose result is discarded, and reports that the work is free. The honest method warms up, consumes the result, and takes many samples, and reports a stable cost with a spread. The timings here are simulated so the numbers are stable, but the disagreement is exactly what you see for real:
The bigger trap: racing snippets nobody runs
Step back from the mechanics for a second, because the most expensive benchmarking mistake is not any of the ones above. It is spending an afternoon proving that x * 2 beats x << 1, or that one loop form shaves a nanosecond off another, when that line is a rounding error in your program’s actual runtime. A perfect measurement of an irrelevant thing is still a waste. Worse, the sterile-environment problem means your “winner” may quietly lose once it meets real, polymorphic, allocation-heavy, cache-missing traffic.
The instinct to reach for a microbenchmark is usually a sign you have not yet measured where the time actually goes. So measure that first. Run the real workload, or a realistic proxy of it, under a profiler, and let it tell you which functions dominate. Then, and only then, if some specific hot function is worth optimising, write a focused benchmark of that, honestly, and let it guide the change. Optimising the thing the profiler pointed at beats winning a race nobody in your program is running.
That is exactly where this part goes next: reading a flame chart to find the code that actually costs you, and optimisation in practice to fix it with everything the pipeline chapter taught you.
Summary
- A microbenchmark is a tiny program, which makes it easy for the engine to “optimise” in ways that destroy the measurement. Most hand-rolled ones are wrong.
- Dead-code elimination deletes work whose result you never observe. Consume the result: accumulate it into a value that leaves the function.
- Constant folding deletes work whose inputs are compile-time constants. Vary the inputs so the engine cannot precompute the answer.
- Warm-up means early iterations run interpreted and climb the tiers, so timing them measures the compiler. Discard warm-up, and remember the steady-state number only matters if your code actually runs hot.
- A sterile benchmark keeps code monomorphic and flatters it; a clumsy harness can go megamorphic or deoptimise and slander it. You cannot fully reproduce production inside a snippet.
- Noise from GC pauses, CPU frequency changes, other processes, and timer resolution means one number is meaningless. Take many samples and read the distribution: median and spread, not a mean.
- Prefer a real benchmarking library over a timer and a loop; it handles warm-up, batching, sinks, and statistics for you.
What this means for your code: before you benchmark anything, profile a real workload and confirm the line is worth your time. When you do benchmark, do not fight the optimiser by hand. Reach for a tool that already knows these traps, measure inputs that resemble reality, and trust a distribution over a single lucky number.