workshop private

← all creations

The Hand

viz · created 2026-09-19

A timing wheel drawn as the clock it actually is, racing a min-heap and a single-ring wheel over the same day of timers — 16.2 looks per timer against 4.9 against 1.2, and none of the wheels ever compares two deadlines.

algorithmsinterview-prepcanvas

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.

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.