Timers arrive, timers come due, and the only question any of this answers is which ones are due now. Three structures answer it side by side, fed the same arrivals from the same seed, for the same simulated day.
On the left, a min-heap: the answer everybody gives. In the middle, one ring of sixty buckets with a hand sweeping it a minute at a time. On the right, the same ring with a second one behind it — an hours wheel, twenty-four slots, its own slower hand. It is a clock, because a timing wheel is a clock, and the whole trick is that a clock never sorts anything.
Each panel counts the same elementary act: examining one timer. The heap calls those comparisons — is this deadline smaller than that one. The wheels call them inspections — the hand landed on this timer, is it due. Same unit, one timer looked at, which is the only reason three numbers this different are comparable at all.
What a day costs
At the defaults — about 1.2 timers a minute, a third of them scheduled past the hour — one simulated day serves 1,355 timers and the counters read:
Heap: 21,911 comparisons. 16.2 per timer. The pyramid shows why. Every insert climbs, every pop sifts back down, and the cascade is visible each time. The split is the part nobody expects: 2.2 comparisons per insert, 13.4 per pop. Inserting is cheap because a far-future deadline sinks to the bottom and stops after one comparison — the heap’s bill is almost entirely on the way out, not the way in.
One wheel: 6,604 inspections, 0 comparisons. 4.9 per timer. Placing a timer
here is deadline % 60 and a division, and no deadline is ever compared to any
other. Even so, it looks at a timer nearly five times to serve it once, because
5,249 of those inspections — 79% — found nothing due. Those are the red
beads: anything further out than one revolution carries a count of whole turns
it still has to sit through, and the hand reads that count, decrements it, and
puts the timer back down. Every hour. This is not a strawman; it is Netty’s
HashedWheelTimer, remainingRounds and all.
Two wheels: 1,683 inspections, 0 comparisons. 1.2 per timer. A timer due inside the hour goes into a minute slot and fires when the hand arrives. Every other timer sits in exactly one hour slot and is examined exactly once more, on its way down — 328 cascades for the day. The counter never wanders, because every inspection is either a firing or a cascade. There is no third kind.
So the ordering is heap, then one wheel, then two, and the gap between the two wheels is entirely re-reading. The wheel’s famous O(1) insert was never the interesting claim; what you do with the timers you can’t reach yet is.
Two things that fall out
Drag “timers past the hour” to 0% and the two wheels become the same wheel. Not approximately — both panels read 1,683 inspections, 1.0 per timer served, and the right-hand panel says so out loud: 0 cascades — this is the single wheel. The hour ring is empty because nothing was ever far enough away to need it, and a hierarchy with one occupied level is a flat wheel with extra bookkeeping. The hierarchy is not a better data structure; it is a structure that only exists when your timers outrun your horizon.
Drag it to 100% and the wheel nearly gives the whole advantage back. The single wheel goes to 17.4 inspections per timer against the heap’s 20.3, and 94% of its looks find nothing. It is still winning, barely, and only because the heap got worse too — a workload of long timers keeps more of them alive at once, so the heap is deeper and every pop costs more. Two structures degrading for unrelated reasons, converging on roughly the same bad number. The two-ring clock, meanwhile, goes from 1.2 to 2.1: one fire plus about one cascade per timer, exactly as advertised.
Reuse
src/timers.js is a framework-free ES module with no dependencies and no DOM —
three structures with the same shape of API, plus the arrival generator and a
seeded PRNG so any run can be reproduced.
TimerHeap—push,popDue(now), withcomparisonsand the indexpathof the last sift, which is what the pyramid draws.FlatWheel— one ring.add(timer, now)computes the slot and the rounds;tick(now)sweeps one bucket. Carriesinspectionsandskipped.HierWheel— minutes in front, hours behind,cascadescounted, pluscascadinglisting what moved on this tick so a view can animate it.rng(seed)/arrivals(next, {rate, longShare, now})— the workload.
The rounds arithmetic is the one place worth copying carefully.
Math.floor(delay / 60) is the obvious formula and it is wrong for exact
multiples of an hour: a timer due in exactly 60 minutes needs zero full
revolutions, not one. It is Math.floor((delay - 1) / 60), and the off-by-one
only shows up on round numbers, which is exactly what a test suite full of
60, 120, 1440 will hand you.
scripts/screenshot-demo.mjs boots demo/ in a real Chromium and fast-forwards
a fixed number of ticks from a fixed seed; --ticks=, --long= and --rate=
set up the shot.
Gotchas
The three counters are not one metric. A heap comparison and a wheel inspection are both “look at one timer”, which makes them honest to put on the same axis, but they are not the same instruction and the constant factors differ. The direction of the result is safe; a 13× ratio is not a 13× wall clock.
The hierarchy’s work is bursty and the sparkline says so. The minute ring ticks along flat, then every sixtieth tick an hour bucket empties in one go — 109 timers in a single minute in the second screenshot. Amortized it is excellent; if you need a bounded worst case per tick it is not, and that is a real property, not a rendering artifact. Linux dropped cascading from its timer wheel for something like this reason: since 4.8 a timer is placed once, in a level whose granularity is coarser the further out it sits, and long timeouts are allowed to fire a little late instead of being re-deposited.
The heap gives you something the wheels don’t. peek() is the next
deadline, always, for free. Neither wheel can answer “when is the next timer
due” without scanning, and neither fires anything earlier than its own tick
granularity. This piece scores the structures on one question; systems pick
them on several.
Cancellation isn’t modelled. Real timer sets are mostly cancelled — a request completes, its timeout is thrown away unfired. That pushes further in the wheels’ favour, since a cancelled timer holds a bucket slot and costs one inspection to discard, while a heap either pays to remove it or carries it until it reaches the top. Modelling it would have made the argument easier and the picture busier.

