deep dive 1 You cannot scan a table for "what's due now"
The single server's poll loop has hit the wall. This is the heart of the whole board, and it is worth being precise about what breaks, because the fix follows straight from it. The problem is not that tasks arrive too fast — accepting them is cheap. The problem is that finding the due ones means looking through the pending ones, and the pending ones are a vast, mostly-far-future set that grows without bound. Hundreds of millions of tasks are pending, scheduled for every imaginable future instant, and a few thousand of them come due every second.
The obvious first answer is a database table indexed by fire time that you poll every tick, and it is exactly what the single server did. It is familiar and durable, and an index on the fire time sounds right. But the workload kills it: Airbnb's own Quartz-on-a-relational-database saturated one big instance at about a thousand queries a second, each enqueue amplified into four SELECTs and three INSERTs, and more read replicas do not help because the bottleneck is the constant write-and-lock churn on the due-soon rows the poll is sweeping, and replicas do not take writes. A table you poll is fighting a workload that scans the whole future-dated set every second.
So the requirement sharpens: finding what's due has to be a cheap pop off the front, not a scan. A durable time-bucketed index gives that — group the pending tasks into buckets by when they are due, front the bucketing with a named hierarchical timing wheel (Kafka's Purgatory), and finding what's due is reading the bucket under the clock hand, a cost that does not grow with the pending set. The Redis sorted-set queue is a real in-production alternative that also beats the table; the bucketed timing wheel wins here on the cleanliness of the due-now pop at this scale. And the timing wheel is not invented at the whiteboard — it is a named, benchmarked primitive, so the design commits to it and does not re-derive its bucket arithmetic.
A durable, time-bucketed index fronted by a hierarchical timing wheel. The obvious first answer is the one the single server used, scaled up: keep the tasks in a database table indexed by fire time, and poll it. Add read replicas, buy a bigger box. It is familiar and durable, and the attraction is real — you already run databases, and an index on the fire time sounds like exactly the right tool. But watch what the workload does to it. Airbnb ran precisely this design — Quartz on a large relational database — and measured where it broke: one big instance saturated at about a thousand queries a second, with each task enqueue amplified into four SELECTs and three INSERTs. A thousand a second is less than a quarter of the derived average due-rate of forty-six hundred, before any peak multiplier. And here is why more replicas do not save it: the bottleneck is not read capacity, it is the constant write-and-lock churn on the due-soon rows — marking tasks fired, contending on the rows the poll is sweeping — and read replicas do not take writes. You cannot replica your way out of a write-and-lock bottleneck. A table you poll is fighting the workload.
So the requirement sharpens: the structure has to make what's due now a cheap pop off the front, not a scan of everything. That is what a durable time-bucketed index gives you. Instead of one big table sorted by fire time, group the pending tasks into buckets by when they are due — a bucket for each small slice of time — so that finding what's due now means looking only at the bucket for now, never at the buckets for next week. Fronting that bucketing is a named, decades-old structure built for exactly this: a hierarchical timing wheel, the same one Kafka uses inside its Purgatory to track millions of in-flight timeouts. Picture a clock hand sweeping a ring of buckets, one bucket per tick; a task scheduled for a given instant is dropped into the bucket the hand will reach at that instant, and when the hand arrives, everything in that bucket is due. Inserting a task is dropping it in a bucket, and popping the due ones is reading the bucket under the hand — both cheap, and neither depends on how many tasks are pending elsewhere. For due times too far out for one ring, higher-level wheels at coarser resolution cover the longer range, and a task cascades down to a finer wheel as its time approaches. And it is durable: the buckets are backed by a key-value store at about a kilobyte per task, so a crash does not lose the pending set.
There is a genuine alternative worth a real hearing, because a named company ships it: a Redis sorted-set delayed queue, the shape Netflix built on Dynomite. Keep the pending tasks in a sorted set scored by fire time, poll the due ones up to now, and claim each atomically. It works, it is in production, and its command-level mechanics are clean — one small script closes the poll-and-claim race, and instead of busy-polling you peek the next-lowest score and sleep until that instant. Its cost is in the constant factor: Netflix's design needs three coordinated structures per queue — a sorted set of queued items, a hash of payloads, and a second sorted set of in-flight items — so it carries more index entries per task than a single-record store, and it shards by availability zone rather than by the timing wheel's bucketing. It is a real, defensible choice, and the reason it is not the pick here is only that the bucketed timing wheel makes the what's-due-now pop cleaner at the pending-set sizes this board is built for. Both beat the table.
Two things are worth pinning under the hood. First, notice which part is cheap and which is the wall. The timing wheel's own throughput is enormous — a single node handles a hundred and five thousand requests a second at a million outstanding timers, roughly twenty times the derived average due-rate — so the algorithm for finding what's due is not where this board strains. The wall is everything durable behind it: holding four hundred million tasks on disk, replicated, and split across machines, which is the next use case. Naming which wall bites is half the skill here, and it is not the timing wheel. Second, there is a shortcut worth knowing: a task due very soon — Dynein uses fifteen minutes — does not need to go through the durable time-bucketed index at all. It can skip straight to the dispatch path, because it will fire before the index would even finish settling it. So the index only ever holds the genuinely-future tasks, and the short-fuse jobs take a fast lane.
Under the hood — how it works
The pick sizes cleanly on both axes, and the interesting part is which one is the wall. Its throughput is the due-time firehose — the derived forty-six hundred dispatches a second on average, tens of thousands at peak — and a single timing-wheel node sustains about a hundred and five thousand a second with a million outstanding timers, so the indexing rate is comfortable and the algorithm is not the ceiling. Its capacity is the pending set itself — the derived four hundred million tasks at about a kilobyte each — and that is the number that will not fit on one machine, walked in full next. Its failure story is the one that matters most on this board: the index is durable, because a lost task is a job that silently never runs and never self-heals — the exact opposite of a throwaway cache, where a lost entry is refetched — so the pending set is written durably and replicated, a crash replays from the backing store, and the next use case shards it so that losing one machine loses only its slice. Timeliness is the soft edge: Dynein targets a ninety-fifth-percentile scheduling deviation under ten seconds, so due now means within a small bounded window, not to the microsecond.
The hierarchical timing wheel is Kafka's Purgatory: a base wheel of one-millisecond ticks across twenty buckets with overflow wheels at coarser resolution, constant-time deletes and near-constant inserts, benchmarked at a hundred and five thousand requests a second with a million outstanding timers against roughly twenty-five thousand for the old priority-queue design. The polling ceiling is Airbnb's: their original Quartz scheduler on a large relational instance saturated at about a thousand queries a second, each enqueue amplified into four SELECTs and three INSERTs, which is why they rebuilt the index on a durable key-value store at about a kilobyte a task. The teaching is the shape, not a mechanism to step through: bucket tasks by due time so finding the due ones is a pop of the bucket under the clock hand, never a scan of the whole future-dated set — which is why no generic sim is linked onto the signature.
The hierarchical timing wheel is Kafka's Purgatory: a base wheel of one-millisecond ticks across twenty buckets with overflow wheels at coarser resolution, constant-time deletes and near-constant inserts, benchmarked at a hundred and five thousand requests a second with a million outstanding timers against roughly twenty-five thousand for the old priority-queue design. The polling ceiling is Airbnb's Dynein: their original Quartz scheduler on a large relational instance saturated at about a thousand queries a second, each enqueue amplified into four SELECTs and three INSERTs, which is why they rebuilt the index on a durable key-value store at about a kilobyte a task. The Redis sorted-set alternative is Netflix's Dynomite delayed queue.
Confluent — Kafka, Purgatory, and Hierarchical Timing Wheels; Andy Fang — Dynein, Airbnb Engineering; Netflix Technology Blog — Distributed delay queues on Dynomite ↗