Hotshard

Design a Distributed Job Scheduler

A client hands the system a task and a time to run it: run this at 3pm, retry this payment in five minutes, send this reminder in thirty days. The system holds the task, does nothing until its moment arrives, and then runs it — within a second or two of the instant asked for, reliably, even if the machine that was going to run it has just crashed. Multiply one such task by a whole company and you are holding hundreds of millions of pending tasks at once, most of them far in the future, while tens of thousands come due every second and have to be fired right now. Build the system that accepts those tasks, finds the ones that are due at each instant without grinding to a halt, and runs each one once from the caller's point of view. This is a distributed job scheduler.

Predict ~1.5 h · Read ~50 min

The problem i

A job scheduler looks like it has two hard parts — accepting a task and running a task — and it turns out both of those are the easy parts. The hard part is the thing in between, the part with no obvious moving pieces: at every instant, out of hundreds of millions of tasks scheduled for every imaginable future time, which ones are due right now?

Here is the first fact. Accepting a task is trivial. A task is a tiny opaque record — an id, a time to fire, a payload the system never looks inside, a status. Writing that record down is one durable write. And running a task, once you have it in your hand, is just handing its payload to a worker and letting the worker do whatever the payload says. If those were the whole problem, this would not be a system worth designing.

Here is the second fact, and it is where the wall is. The obvious way to find what's due is to keep every task in a table with its fire time, and every second run a query: give me every row whose fire time has passed. On a small table that is instant. But the pending set is not small — it is hundreds of millions of rows, and the vast majority of them are nowhere near due, scheduled for hours or days or weeks from now. Running a query that scans, or even range-scans and locks, across that whole future-dated table every single second, forever, is a workload that no single database survives. The scheduled tasks pile up faster than the poll can sift them. The moment you have a real fleet's worth of pending tasks, finding what's due stops being a cheap question and becomes the entire problem.

That flips the shape of the design. The scary-sounding parts — swallowing tasks, running tasks — are routine. The quiet part in the middle, answering what's due now cheaply over an enormous set of future-dated tasks, is the wall, and getting past it is what the whole board is about. It forces a purpose-built structure that indexes tasks by when they are due, so that finding the due ones is a cheap pop off the front instead of a scan of everything.

There is a second strand, and it is about what happens after a task comes due. Finding a due task is worth nothing if you then lose it. You hand the task to a worker, and the worker crashes halfway through — is the task gone? A payment never retried, a reminder never sent, silently. So handing a task to a worker cannot be a fire-and-forget send; it has to be a hand-off you can take back if the worker dies, so the task gets run by someone else. That safety has a price you pay on the other side: if you are willing to re-run a task whose worker might have died, then sometimes a task runs twice — once by the worker that seemed to die and once by its replacement. So the task's effect has to be safe to repeat. The reliability story here is not that the task runs exactly once, which no real scheduler promises; it is that the task is never dropped, and running it twice does no harm.

One idea frames why all of this is worth the trouble. The naive design — one table, one poll loop asking what's due every second — is not just slow at scale, it is impossible at scale, and seeing exactly where it dies is the whole point of this board. What replaces it is a small set of pieces, each forced by a number: a durable index that keeps tasks sorted by due time so finding the due ones is cheap, that index split across many machines because the pending set does not fit on one, a hand-off to workers you can take back when they die, and a key that makes re-running a task harmless. Every piece later in this article is one of those, introduced where the numbers force it.

Two timings are worth pinning now, because decisions later turn on them. Writing a task durably, so a crash cannot lose it, costs about ten milliseconds. Popping the due tasks off the front of an in-memory index costs about one. And a same-datacenter hop between the pieces of the system is a millisecond or two. The gap between a cheap pop off a purpose-built index and a scan of a huge table is exactly the gap the whole design exists to stay on the right side of.

Functional requirements what it must do

  • Accept a task to run at a future time, and hold it durably until then.
  • At every instant, find the tasks that are now due, cheaply.
  • Fire each due task within a second or two of its due time.
  • Run each due task even if the worker handling it crashes.
  • Never let a task's effect happen twice from the caller's point of view.

Non-functional requirements what it must be

  • Answer what's due now as a cheap pop, not a scan.
  • Survive a machine loss without losing pending tasks.
  • Treat a duplicate run as safe and a lost task as unacceptable.
  • Spread synchronized due-times so one instant cannot overload the system.

Out of scope left out on purpose

  • Placing workloads onto machines across a cluster
  • Dependencies between tasks
  • The data model for a task
  • Exactly-once execution as a consensus proof
  • The internals of the time-indexing data structure

How the design works

The finished design, explained one piece at a time: first the contract the system serves, then the smallest version that works, then the version that survives scale — walked use case by use case, one deep dive at a time, with the finished diagram at the end.

The API

The whole product is these endpoints. Every box in the design below exists to serve one of them.

SCHEDULE taskTake in a task — give a future instant, an opaque payload, and a client-supplied key that makes the run safe to repeat. The system writes the task durably and returns its id. This is the submit side, and it happens at the enqueue rate.
contract ▾
# The submit side — write a task down durably, return its id.
 schedule(fire_time, payload, idempotency_key)
 task_id                       # the task is now pending until its due time
CANCEL taskCancel a task — a client that changes its mind before the task fires removes it by id. A cancel after the task has already fired is a no-op.
contract ▾
# Remove a still-pending task before it fires.
 cancel(task_id)
 ok                            # a no-op if the task has already fired
FIRE by-due-timeThe system fires the task — no caller asks for this; when a task comes due the system hands its payload to a worker and runs it. Firing is the whole point, and it is driven by the clock, not by a request.
contract ▾
# Not caller-driven — the clock fires a task when it comes due.
when fire_timenow: lease the task to a worker · run it · ack
 (no caller FIRE) — due tasks are dispatched by the system, at-least-once

part 1 The single-server version

Start with the smallest thing that works: one app that schedules tasks, and one server that stores them and runs them when they are due.

Start with the vocabulary, because these few words carry the whole problem. A client submits a task — give it an id, a due time (the fire time, the instant it should run), and an opaque payload the system never looks inside. The server writes that task to a table. Until the task's due time arrives it is pending; once the server has run it, it is fired. To find what's due, the server runs a poll loop: every tick — say once a second — it asks the table for every row whose fire time has passed and whose status is still pending, runs whatever comes back, and marks each one fired. And there are two ways it can hand off a task to run it. Fire-and-forget means it runs the task and forgets it — simple, but if the server crashes mid-run the task is just gone. At-least-once means it does not consider the task done until the run is acknowledged, and retries otherwise — safer, at the cost of sometimes running a task twice. Those words — a task, its due time, pending versus fired, the poll loop, fire-and-forget versus at-least-once — carry everything that follows.

The day-one design is direct. There are two boxes. An Intake service takes a client's schedule call and writes the task into the store. And a Due-time index — here just a single-server table — holds the pending tasks and runs the poll loop that finds and fires the due ones. When a task's moment arrives, the poll loop's query surfaces it, the server runs its payload, and the row is marked fired. For one app scheduling a handful of tasks, this is genuinely fine, and it already has the shape the whole design keeps: write a task down, and repeatedly ask which are due and run them.

Now the teaser, and it is worth stating plainly even though the single server never feels it, because it is what justifies every later piece of this design. This one server is holding a handful of tasks. A real company holds hundreds of millions of pending tasks at once — most of them scheduled for far in the future, a reminder next week, a recurring job next month — while, derived from Uber's Cadence running over twelve billion executions a month, on the order of a few thousand come due every second on average and tens of thousands at peak. Point the poll loop at that. Every single second, the query has to sift a table of four hundred million future-dated rows to find the few thousand that are due. That query does not run in a millisecond over four hundred million rows; it scans or range-locks a huge table, again and again, forever. Airbnb measured exactly this on their own early scheduler, and it saturated one large database at about a thousand queries a second — less than a quarter of the derived average due-rate, before any peak. The poll loop is the piece that dies, and it dies at a number the single server never sees. That is the wall, invisible at this size and unavoidable at fleet size, and the scaled section does the arithmetic out loud when it reaches it.

appschedules tasksIntake service· stateless APIstatelessDue-time index ·single-server tablepoll loop · one node
The whole system on day one.
Following a request
A task in from a client
  1. the app schedules a task — a future fire time and an opaque payload~1 ms
  2. the Intake service validates it and writes the task durably into the table, which a poll loop scans every tick to find and run the due ones~10 ms

One hop dominates every path: the client's request crossing the public internet to reach the datacenter. That hop is tens of milliseconds — call it about 30 — and it is set by physical distance, not by anything we build. Everything after it happens inside a single datacenter, where the network between two machines is well under a millisecond — so those numbers are the actual work, not the wait: a durable write to disk about 10 ms, an indexed read about 5 ms. A hop between two datacenters would cost 10–100× the in-datacenter number, which is why the whole path stays in one region.

This one-server design does not survive being pointed at a real fleet, and it fails in two places at once. It runs out of the ability to find what's due: the poll loop's scan over a huge future-dated table cannot keep up with the rate at which tasks come due, and that is the wall the very next use case is about. And it runs out of room to hold the pending set: hundreds of millions of durable tasks do not fit on one machine's disk, and if that machine dies every pending task on it is lost, which for a scheduler means a silent wave of jobs that simply never run. So the poll can no longer find what's due, and the pending set no longer fits, on any one server. That is what forces the scaled design, and it begins with the wall in the middle.

part 2 The version that survives scale

This section walks the design by use case, in the order you actually have to decide things. First, face the wall in the middle: you cannot scan a table for what's due now, so replace the poll with a durable index that keeps tasks sorted by due time. Then face the size of the pending set: it does not fit on one machine, so shard the index. Then handle a due task safely: hand it to a worker in a way you can take back if the worker dies, and make the run safe to repeat. And finally, watch what happens when a great many tasks all come due at the same instant, and see why spreading them out is not a nicety but load-bearing infrastructure. The finished diagram waits at the end.

Two ways through this section

The four decisions below are the heart of this design, and there are two ways to take them. In the design room you make each one yourself and the board reacts to your pick. The write-ups after this point cover the same decisions in full — read them instead, or afterwards as reference.

use case 1 You cannot scan a table for what's due now

The single-box poll loop is finished — this is where the whole board turns. Taking tasks in was never the hard part. The pain is that spotting the handful due this very second means wading through an enormous, ever-growing pile of tasks spread across all of future time, almost none of them due yet.

deep dive 1 You cannot scan a table for "what's due now"
The question

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.

⚡ From production

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
appschedules tasksIntake service· stateless APIshort-fuse bypassDue-time index · timingwheel + durable KVtime-bucketed
The board after this deep dive — “A task in from a client” traced, hop by hop. The numbered steps below walk the same path.
A task in from a client
  1. the app schedules a task — a future fire time and an opaque payload~1 ms
  2. the Intake service writes the future-dated task into the durable, time-bucketed index — dropped into the bucket for its due instant, backed by a KV store — instead of a polled table~10 ms
use case 2 The pending set is four hundred million tasks

The index makes finding due tasks cheap, but it is still a single machine — and the pending set is what breaks that. Hundreds of millions of durable tasks will not fit on one disk, and one disk is also one place to lose all of them. The whole thing comes down to arithmetic: how many machines, and split how?

deep dive 2 The pending set is 400 million tasks
The question

The index answers what's due now cheaply now. But it still lives on one machine, and the pending set is about to tell us that cannot hold. The pending set is hundreds of millions of future-dated tasks, each about a kilobyte, held durably. That does not fit on one machine's disk, and if it did, losing that machine would lose every pending task on it. This use case is short and it is arithmetic, because the number does all the arguing.

The pick is not really in doubt — a durable set that cannot afford to lose a single task cannot sit on one machine, so you shard. The real choice is the shard key. Shard by a hash of the task id, with consistent hashing, and the pending set spreads evenly and a node addition moves only a small slice; each shard runs its own timing wheel over its slice and replicates it, so a node loss costs neither availability nor a task. Airbnb's rebuilt index gets virtually unlimited linear scalability precisely because the store shards under the hood by partition key — a single-node store gets none of that for free, which is why sharding is a decision, not an afterthought.

The shard key that reads natural and is wrong is fire time: shard by when the task is due, and every task due at the same instant lands on the same node, so the midnight bucket concentrates onto one shard — the exact hot bucket the last use case is about. Hashing by task id spreads that load; sharding by fire time gathers it. And the number does the arguing on why to shard at all: not because a bit over a terabyte is a lot of disk — it isn't — but because a durable set that cannot afford to lose a single task cannot sit on a single machine.

Shard the durable index by a hash of the task id, with consistent hashing and replication. Do the arithmetic out loud, from the bytes up, and flag every derived figure as derived. Start from the one cited anchor: Uber's Cadence runs over twelve billion executions a month, which spread across a thirty-day month is about forty-six hundred dispatches a second on average — derived, not a sourced point-in-time figure. Now ask how many tasks are resident in the pending set at once. By Little's Law, that is the due-rate times how long a task sits pending before it fires. Take a one-day average dwell as a stated planning assumption — some tasks fire in seconds, many are recurring or scheduled weeks out, and one day is a reasonable middle — and the pending count is about forty-six hundred a second times eighty-six thousand four hundred seconds, roughly four hundred million tasks resident at once, derived. At Dynein's measured one kilobyte per task, one full copy of that set is about four hundred gigabytes. And because the index is durable and must survive a machine loss, it is replicated — a standard threefold replication gives a bit over a terabyte fleet-wide, derived. That is small in absolute disk. But it is more than one machine should hold as a single point of loss, and the write-and-index rate across it is more than one machine should serve, so the index is split.

The pick is not really in doubt — you shard — but how you shard is a real choice, and there is a clean way. Split the index by a hash of the task id, using consistent hashing so the pending set spreads evenly across nodes and so adding or removing a node moves only a small slice of tasks rather than reshuffling everything. This is exactly Airbnb's experience: rebuilt on a key-value store that shards under the hood by partition key, the index gets virtually unlimited, linear scalability — but note why that phrase holds. It holds because the store shards automatically by partition key; a single-node store does not get linear scaling for free, which is the whole reason sharding is a decision here and not an afterthought. Each shard runs its own timing wheel over its slice of the buckets, pops its own due tasks, and holds a replicated copy of its slice, so losing one node loses neither availability nor any task. And there is a shard key that reads natural and is wrong: shard by fire time, and every task due in the same instant lands on the same node, so the midnight bucket concentrates onto one shard — the very hot bucket the last use case is about. Hashing by task id spreads that load; sharding by fire time gathers it.

This is the both-sizings habit, and this board is unusual in that two independent walls bite. The first sizing is capacity: the pending set is a count of tasks times bytes per task times replication — the derived bit-over-a-terabyte — and it is split across nodes for durability, not for raw disk. The second sizing is throughput, and it lives on the execution side, not here: the peak due-rate has to be dispatched to workers, and as the next use case shows, a single dispatch queue has a hard ceiling on how many tasks it can hold in flight at once, which forces fanning out across many queues. So you size the index by capacity and the dispatch tier by throughput — separate calculations in separate units — and you provision each for its own wall. The timing-wheel algorithm, notably, is the ceiling on neither — at a hundred and five thousand a second per node it is roughly twenty times the average due-rate. The walls are the durable storage here and the dispatch-queue depth next, never the algorithm.

Under the hood — how it works

The capacity chain is derived from one cited anchor, and every figure is flagged. Uber's Cadence runs over twelve billion executions a month, about forty-six hundred a second spread over a thirty-day month (derived). By Little's Law, the resident pending count is the due-rate times the dwell — a one-day average dwell as a stated planning assumption — so about four hundred million tasks resident at once (derived). At Dynein's measured one kilobyte per task, one full copy is about four hundred gigabytes, and a standard threefold replication for a durable index is a bit over a terabyte fleet-wide (derived). Only the one-kilobyte byte cost inside that chain is sourced; the due-rate, the pending count, and the storage total are Little's-Law derivations over the Cadence anchor plus the stated dwell and replication assumptions, and none is presented as cited.

Each shard is a self-contained durable slice: its own timing wheel over its slice of the buckets, its own pop of the due tasks in that slice, and a replicated copy so a node loss fails over to a replica with no task lost. Consistent hashing is what keeps a node addition from reshuffling the whole set — only the slice between the new node and its neighbor moves. The one thing sharding does not solve is the execution wall: the index can now hold and surface the due tasks, but getting each due task to a worker and surviving that worker's death is a different problem, the next use case. Consistent hashing is committed here as a named, reused primitive, content-first — its ring mechanics are not re-derived and no sim is forced onto it.

⚡ From production

Dynein's index on DynamoDB costs one write-capacity unit to enqueue a task, and a WCU is a write of up to one kilobyte, which is where the one-kilobyte-per-task figure comes from; Airbnb reports virtually unlimited, linear scalability without manual sharding — but explicitly because DynamoDB shards under the hood by partition key, the caveat this dive leans on. The pending-count and storage figures on top of that byte cost are derived here via Little's Law from Uber's Cadence throughput anchor, not sourced.

Andy Fang — Dynein, Airbnb Engineering; Uber Engineering — Announcing Cadence
appschedules tasksIntake service· stateless APIshort-fuse bypassDue-time index · timing wheel + durable KV12 shards · by task_id
The board after this deep dive — “A task in from a client” traced, hop by hop. The numbered steps below walk the same path.
A task in from a client
  1. the app schedules a task — a future fire time and an opaque payload~1 ms
  2. the Intake service writes the future-dated task into the durable, time-bucketed index — dropped into the bucket for its due instant, backed by a KV store — instead of a polled table~10 ms
use case 3 A task is due — now run it, and survive the worker dying

With the index surfacing due tasks on demand, the remaining question is what happens next: a due task still has to land on a worker and actually execute, and that worker can die mid-run. This is the execution side of the system, and it comes only after the hard time-index problem is solved. The goal is to guarantee a task runs even when the machine running it fails, without demanding flawless workers.

deep dive 3 A task is due — now run it, and survive the worker dying
The question

The index holds the pending set and surfaces the due tasks. Now a task is due, and it has to reach a worker and actually run — and the worker might crash halfway through. This is the execution half, and it is where the reliability story is decided. It is deliberately reached after the time-index wall is paid: finding what's due was the signature; running it safely is the supporting mechanism. You need to get a due task to a worker, have the worker run its payload, and be certain the task actually ran — even if the worker it went to crashes mid-run, or the acknowledgement is lost.

The obvious answer is fire-and-forget — pop the due task, send it to a worker, done — and it would be correct if workers never died. But workers die mid-run, before acknowledging, all the time at fleet scale, and fire-and-forget silently drops a task whose worker crashes, trading away the one thing a scheduler cannot trade: never dropping a task. So the hand-off has to be one you can take back. Lease-and-ack with a visibility timeout — a queue like SQS — leases the task with a timer, deletes it on ack, and redelivers it to another worker if the timer expires with no ack. The task is never lost because it is not done until it is acked; the worst case is it runs again. That is at-least-once, what every named production scheduler ships.

At-least-once hands you a bill — a task can run twice — and the clean way to make that safe is a client-supplied idempotency key: the worker records the first run's result under the key and replays it for any redelivered duplicate within the safety window (Stripe honors it for twenty-four hours), instead of re-running the payload. The reliability story is not exactly-once, which no named system promises across arbitrary side effects; it is never dropped, and safe to repeat. Server-side dedup is a defensible variant, but a client key covers the effect's own idempotency where a server table only covers the delivery.

Lease-and-ack with a visibility timeout, at-least-once, made safe by an idempotency key. The obvious answer is fire-and-forget: pop the due task, send it to a worker, done. It is simple, and if workers never died it would be correct. But workers die — mid-run, before acknowledging, all the time at fleet scale — and with fire-and-forget a task handed to a worker that then crashes is simply gone. No retry, no error, a payment silently never retried. Fire-and-forget trades away the one thing a scheduler cannot trade: never dropping a task. So the hand-off has to be one you can take back. That is lease-and-ack with a visibility timeout, exactly what a queue like SQS provides. The dispatcher pops the due task and leases it to a worker — hands it over but keeps it, invisible to other workers, with a timer running (SQS defaults to thirty seconds, up to a twelve-hour maximum). If the worker finishes and acknowledges before the timer expires, the task is deleted, done. But if the worker crashes and the timer expires with no acknowledgement, the task becomes visible again and is redelivered to another worker. The task is never lost, because it is not considered done until it is acked; the worst case is that it runs again. That is at-least-once delivery, and it is what every named production scheduler ships — Dynein, SQS, Netflix's queue — none of them claims exactly-once.

At-least-once buys safety and hands you a bill: sometimes a task runs twice. The worker that seemed to die may have actually finished just as its lease expired, and the redelivered copy runs the payload a second time. So the worker's handler has to be idempotent, and the clean way to guarantee that is an idempotency key. The client supplies a unique key with the task; the worker records the result of the first run under that key, and if the same task arrives again within the safety window — Stripe honors the key for twenty-four hours — it replays the stored first result instead of re-running the payload. A charge that already happened is not charged again; the second run returns the first run's answer. The key is what makes runs-at-least-once safe to live with.

Trace the due-dispatch flow, and note it is authored as hop pairs on the dispatcher, not one long chain. When a task comes due, the Due-time index pops the due task and hands it to the Dispatcher. The Dispatcher, separately, leases the task to a worker in the Worker fleet, starting the visibility timer. The worker runs the payload behind its idempotency key, and acknowledges. If the ack arrives in time the dispatcher deletes the task; if the timer expires first, the dispatcher redelivers to a different worker. The popping and the leasing are two separate hops on the Dispatcher; the running and the idempotency check happen inside the worker and are not hops.

Two facts shape the dispatch tier under the hood. First, a single queue cannot hold unlimited leased tasks: a standard SQS queue caps at about a hundred and twenty thousand in-flight — leased-but-unacked — messages. At a peak due-rate in the tens of thousands a second with even a few seconds of average run time, the in-flight count on one queue approaches that ceiling, so the dispatcher fans out across many queues rather than funneling every lease through one. This is the second independent wall the board sizes for — a throughput wall on the execution side, separate from the index's storage wall. Second, a task whose run fails — as opposed to whose worker died — should be retried, but not instantly and not forever: retry it on an exponential backoff with a little jitter so retries do not synchronize, extend the lease with a heartbeat call for a legitimately long-running task so it is not redelivered underneath itself, and after a bounded number of failed attempts move it to a dead-letter queue for a human to look at.

Under the hood — how it works

The Dispatcher is sized by throughput — the peak due-rate against the roughly hundred-and-twenty-thousand-in-flight ceiling per queue — so its queue count is the peak in-flight divided by that ceiling, the second independent wall alongside the index's capacity. It holds only transient state: a queue carries leased-but-unacked tasks, not the durable pending set, which stays in the index. Its failure story is the whole point of the design — a worker dying means its lease expires and the task is redelivered, at-least-once; a task that keeps failing lands in a dead-letter queue; and a dispatcher dying without dropping or double-firing is handled by a hardening beat carried at the next use case (a leader that brackets each launch with a synchronous quorum notify).

The Worker fleet is stateless — it holds no resident state, runs the opaque payload, and scales horizontally with the due-rate, bounded by the dispatcher's fan-out. Its failure story is that duplicates are expected and the idempotency key makes a re-run harmless, a poisoned task is dead-lettered after a bounded number of attempts, and a legitimately slow task heartbeat-extends its lease with a change-visibility call or gets redelivered. The dispatch tier is a message queue — pop a due task, lease it to a consumer, ack or redeliver — and that pop-lease-ack machinery is a runnable model this design reuses without re-teaching, which is why the go-deeper links the message-queue sim. The specific backoff curve on a failed run is general engineering practice here, not a single cited policy, so it is named as the shape, not pinned to a schedule.

⚡ From production

SQS's visibility timeout defaults to thirty seconds and maxes at twelve hours; a message not deleted before it expires becomes visible again and is redelivered, and SQS states plainly there is no guarantee a message is delivered only once — it is at-least-once. A standard queue caps in-flight messages at about a hundred and twenty thousand, with a change-visibility call to heartbeat-extend a long task's lease and a dead-letter queue for repeated failures. Dynein and Netflix's Dynomite queue both ship at-least-once with idempotent handlers required. The idempotency key is Stripe's pattern: a client-supplied key, the first response cached and replayed for any retry within twenty-four hours.

AWS — Amazon SQS visibility timeout; Andy Fang — Dynein, Airbnb Engineering; Stripe — Idempotent requests
appschedules tasksIntake service· stateless APIshort-fuse bypassDue-time index · timing wheel + durable KV12 shards · by task_idDispatcher · lease-and-ackqueue (SQS-style)lease + ack · redeliverWorker fleet ·stateless workersidempotentat-least-oncethe task'sside effectthe task runs
The board after this deep dive — “A due task out to a worker” traced, hop by hop. The numbered steps below walk the same path.
A due task out to a worker
  1. when a task comes due the Due-time index pops it and hands it to the Dispatcher~5 ms
  2. the Dispatcher leases the task to a worker with a visibility timeout, redelivering to another worker if the first dies before acknowledging~1 ms
  3. the worker runs the task's opaque payload behind its idempotency key, so a redelivered duplicate replays the first result instead of re-running~1 ms
use case 4 When everyone scheduled midnight

On the happy path, the whole design is finished. This last use case starts it in mid-collapse — and not from some side issue. What buckles is the board's defining piece, the time-bucketed index, struck exactly where it runs hottest out in the real world: countless teams all choose the same round moment for their daily jobs, so one bucket bulges while its neighbors stay empty, and the whole pile fires the instant the clock hand lands on it. Buying machines will not help; the answer is to smear that load off the round moment at the source.

deep dive 4 When everyone scheduled midnight
The question

Step back and look at what is built. The durable time-bucketed index answers what's due now as a cheap pop, sharded so the pending set fits and survives a node loss; the dispatcher leases each due task to a worker at-least-once and redelivers on a crash; the worker's idempotency key makes a re-run safe. It is complete for the happy path. Now the premise arrives already broken, the way a real incident does — and the twist is that this is not an off-to-the-side failure. This is the signature's own structure, the time-bucketed index, hit on its hot axis in production. Google's own SRE book tells this story: across a datacenter, many independent teams each schedule a daily cron job, and asked when to run it, engineers overwhelmingly pick a round instant — midnight, the top of the hour — because that is the obvious thing to type.

The instinct, in the room and in the moment, is to read a synchronized load spike as a capacity shortfall and reach for the obvious lever — the midnight bucket overwhelms us, so add machines, give it headroom. And it is exactly the wrong move. The load is not evenly distributed and under-provisioned; it is pathologically concentrated on specific instants and idle between them. Provisioning for the spike pays for a fleet that sits nearly idle the rest of the day, and the spike can always grow past whatever you provision as more teams pick midnight. A multiplicative concentration is not bought out with a linear addition of capacity.

The fix that actually worked de-concentrates the load at its source: a hashing operator that spreads each job's launch time across a window instead of the dot of midnight, so the clock hand meets an even trickle instead of a wall, with the index's own sharding spreading any residual hot bucket across nodes. The reframe the whole board rewards is that the hot bucket lives on the due-time axis the index is built on, so the fix lives on that axis too — and jitter is load-bearing infrastructure, not a tuning knob, because Google's own data shows the residual spikes are real even with the spread in place.

Spread the synchronized due-times off the round instant — jitter as load-bearing infrastructure. So thirty teams' unrelated daily jobs all come due at exactly the same second. On the time-bucketed index, that means one bucket — the midnight bucket — holds a massive pile of tasks while its neighbors are nearly empty, and when the clock hand reaches it, all of that load fires at once. It is a hot time bucket: a thundering herd of synchronized due-times landing on a single slice of the index. And Google's own data shows it is not a one-time fluke — the launch spikes cluster at fixed schedule times and persist even after the first mitigations, a recurring production pattern, not a single dated glitch. The instinct, in the room and in the moment, is to read a synchronized load spike as a capacity shortfall and reach for the obvious lever: the midnight bucket overwhelms the system, so add more machines, give it headroom for the spike. And it is the wrong move, for a reason that goes to the shape of the problem. The load is not evenly distributed and under-provisioned; it is pathologically concentrated on specific instants and idle between them. Provisioning for the midnight spike means paying for a fleet that sits nearly idle the rest of the day, and the spike can always grow past whatever you provision as more teams pick midnight. You cannot buy your way out of a concentration problem with capacity; you have to de-concentrate the load.

The fix that actually worked was to stop pinning jobs to the literal round instant. Google added a hashing operator to the schedule that spreads each job's actual launch time across a configured window instead of landing it on the dot of midnight. The midnight pile smears out across many buckets, and the clock hand meets an even trickle instead of a wall. Where a bucket is still hot, sharding the index by task id spreads even that bucket's tasks across nodes. The reframe to carry out of this incident is the one the whole board rewards: the hot bucket is a property of the due-time axis, the same axis the index is built on, so the fix lives on that axis too. Jitter here is not a nice-to-have you sprinkle on at the end; it is load-bearing infrastructure — even with the spread in place the residual spikes are real, so the spreading is a permanent, necessary part of the design, not a one-time cleanup.

There is a second, distinct reliability layer here under the hood, and it is worth separating cleanly from the worker's idempotency key. That key protects against a worker running a task twice. But what protects against the dispatcher — the thing that leases tasks — dying mid-launch and its replacement re-firing a task that already went out? Google's cron service answers with a leader that synchronously notifies a quorum both before and after each launch: if it skipped the after notice, a leader that died right after launching would let its successor re-launch the same job. And every launched job's identity embeds its scheduled fire time, so a newly elected leader can look at the record of completed launches and skip the ones already done. Leader failover completes within about a minute. This is a different layer from the worker idempotency key — one dedupes at the dispatcher on leader change, the other dedupes at the worker on redelivery — and a strong answer names both without conflating them.

Under the hood — how it works

The account is Google's own, from the SRE book's chapter on distributed periodic scheduling: many teams scheduling daily jobs at the same round instant in one datacenter produce a severe synchronized load spike; the launch spikes cluster at fixed schedule times and persist even after mitigation; the fix is a hashing operator that spreads each job's launch across a time range. The jitter/hash-spread fix has no box and no sizing of its own — it is a policy on how due times are assigned, smearing a spike flat across the index's buckets. Its failure story is subtle and honest: even correctly applied, it does not eliminate the spikes, only flattens them, so it is combined with bucket sharding for any residual hot bucket and treated as permanent infrastructure rather than a fix that is ever done.

The leader-dedup hardening is a distinct layer from the worker idempotency key, and a strong answer names both without conflating them: the idempotency key dedupes at the worker on redelivery; the leader/quorum notify dedupes at the dispatcher on leader change. Google's cron service uses a leader that synchronously notifies a quorum both before and after each launch — the after notice is what stops a leader that died right after launching from letting its successor re-launch the same job — and embeds the scheduled fire time in each job's identity so a new leader dedupes against completed launches, with failover within about a minute. Its own failure edge: a leader that notifies asynchronously, or forgets the post-launch notice, reintroduces the double-launch it exists to prevent, which is precisely why the quorum notify is synchronous and brackets the launch on both sides.

⚡ From production

The account is Google's own, from the SRE book's chapter on distributed periodic scheduling. Many teams scheduling daily jobs at the same round instant in one datacenter produce a severe synchronized load spike; the launch spikes cluster at fixed schedule times and persist even after mitigation; the fix is a hashing operator that spreads each job's launch across a time range. The same source describes the leader-quorum launch-dedup mechanism — a synchronous pre-and-post-launch quorum notification plus fire-time-embedded job identity, with failover within about a minute.

Google — Site Reliability Engineering, Distributed Periodic Scheduling with Cron
appschedules tasksIntake service· stateless APIshort-fuse bypassDue-time index · timing wheel + durable KV12 shards · by task_idDispatcher · lease-and-ackqueue (SQS-style)lease + ack · redeliverWorker fleet ·stateless workersidempotentat-least-oncethe task'sside effectthe task runs
The board after this deep dive — “A due task out to a worker” traced, hop by hop. The numbered steps below walk the same path.
A due task out to a worker
  1. when a task comes due the Due-time index pops it and hands it to the Dispatcher~5 ms
  2. the Dispatcher leases the task to a worker with a visibility timeout, redelivering to another worker if the first dies before acknowledging~1 ms
  3. the worker runs the task's opaque payload behind its idempotency key, so a redelivered duplicate replays the first result instead of re-running~1 ms
Where this design sits on consistency (and CAP)

This design makes opposite durability choices for its two kinds of state, on purpose, because they are worth completely different things. The pending set — the future-dated tasks in the Due-time index — is the authoritative, durable state, and it leans hard toward durability. A lost pending task is a job that silently never runs and never self-heals, the one failure this system cannot accept, so the index is written durably, replicated, and sharded so that losing one machine loses only its slice, which fails over to a replica with no task lost. This is the tier the whole design pays for with replication and durable writes.

The dispatch path makes the other choice. Once a task comes due, getting it to a worker is availability-leaning and at-least-once: on any doubt about whether a worker ran it, the dispatcher redelivers rather than risk dropping it, so a task may run more than once. That is a deliberate trade — the system errs toward running a task again rather than missing it — and it is made safe on the other side by a client-supplied idempotency key that lets the worker replay the first run's result instead of re-executing. The dispatcher's leased-but-unacked state is transient, not the source of truth; the durable pending set stays in the index.

There is a name for the underlying choice. When a network fault splits a system in two — that split is the P, for partition — each operation has exactly two honest options: answer anyway from what this half knows and risk being wrong, which is the A, for availability, or refuse until the halves agree, which is the C, for consistency. A partition is not something you choose; it is weather. The useful way to apply the CAP theorem is per operation, by asking what a wrong answer would cost. Here a duplicate run costs almost nothing once handlers are idempotent, while a dropped pending task costs everything, so the design takes durability for the pending set and availability plus at-least-once for dispatch — redeliver on doubt, and let the idempotency key absorb the duplicate. Google's own thundering-herd account is the same lesson in the field: keep the system up by spreading the load off the round instant at the source, not by refusing work or over-provisioning for a spike.

CAP theorem — the partition trade-off, per operation
The finished board

That is the whole machine, a submit path and a due-dispatch path meeting at the durable index, and every box on it was placed by one of the deep dives above. On the submit path a client calls the Intake service with a task and its due time; the Intake service validates it and writes it into the Due-time index — a sharded, durable, time-bucketed index that keeps pending tasks sorted by when they are due, fronted by a hierarchical timing wheel so what's due now is a cheap bucket pop, and hash-spreading synchronized due-times so no bucket becomes a thundering herd. A short-fuse task due within fifteen minutes skips the index straight to dispatch. On the due-dispatch path, when a task comes due the Due-time index hands it to the Dispatcher, which leases it to a worker in the Worker fleet with a visibility timeout, redelivering to another worker if the first dies before acknowledging. The worker runs the task's opaque payload behind an idempotency key, so a redelivered duplicate replays the first result instead of re-running. If you carry away one sentence, make it the one the whole design turned on: when a system has to repeatedly answer which items match a condition right now over a huge set, do not scan the set on each ask — maintain an index keyed by the very thing the condition tests, so the answer is a cheap lookup instead of a scan.

appschedules tasksIntake service· stateless APIshort-fuse bypassDue-time index · timing wheel + durable KV12 shards · by task_idDispatcher · lease-and-ackqueue (SQS-style)lease + ack · redeliverWorker fleet ·stateless workersidempotentat-least-oncethe task'sside effectthe task runs
The finished design — every box placed by one of the deep dives above.
What's on the diagram
app
app. The write-side actor — a client that submits a task and a future instant to run it (run this at 3pm, retry this payment in five minutes), then waits for nothing. Accepting the task is the cheap half; the system writes the tiny opaque record down durably and returns its id, and the whole design is about what happens between that write and the instant the task comes due.
Intake service · stateless API
Intake service · stateless API. The write-side front door: it accepts a client's schedule call, validates it, and writes the task into the Due-time index, returning the task's id. It holds no durable task state, only the in-flight request, and is sized by the submit rate and scaled horizontally; a crash there loses only in-flight submits, which the client retries, deduped on the task id. It also owns the short-fuse fast lane — a job due within fifteen minutes skips the index straight to the Dispatcher, because it will fire before the index would even settle it.
Due-time index · timing wheel + durable KV
Due-time index · timing wheel + durable KV. The board's hero and the hub both paths touch: a durable, time-bucketed index of pending tasks fronted by a hierarchical timing wheel, so what's due now is a cheap pop of the bucket under the clock hand rather than a scan of the whole future-dated set. It is backed by a key-value store at about a kilobyte a task, sharded by a hash of the task id with replication, and hash-spreads synchronized due-times across a window so no single bucket becomes a thundering herd. It is sized by capacity — the derived four hundred million pending tasks at a bit over a terabyte, sharded — while its indexing throughput is comfortable because the timing wheel runs far above the due-rate; it is durable and replicated because a lost pending task is a job that silently never runs.
Dispatcher · lease-and-ack queue (SQS-style)
Dispatcher · lease-and-ack queue (SQS-style). The execution front: when a task comes due the Dispatcher pops it from the index and leases it to a worker with a visibility timeout, deleting it on acknowledgement and redelivering it to another worker if the first dies before acking — at-least-once delivery, so a worker death costs a redelivery, not a dropped task. It holds only transient leased-but-unacked tasks, not the durable pending set, and is sized by throughput — the peak due-rate against the roughly hundred-and-twenty-thousand-in-flight ceiling per queue, fanned across many queues; a dispatcher dying without double-firing is handled by a leader that brackets each launch with a synchronous quorum notify.
Worker fleet · stateless workers
Worker fleet · stateless workers. The run side: interchangeable stateless workers that take a leased task, run its opaque payload, and acknowledge — guarded by a client-supplied idempotency key, because delivery is at-least-once and a redelivered duplicate must replay the first run's result instead of re-executing. It holds no resident state, scales horizontally with the due-rate, and its failure story is that duplicates are expected and made harmless by the key; a poisoned task is dead-lettered after a bounded number of attempts, and a legitimately slow task heartbeat-extends its lease.
the task's side effect
the task's side effect. The run-side actor — whatever the task's opaque payload actually does when it fires: a payment charged, a reminder sent, a webhook called. The system never looks inside the payload; its whole job is to make that effect happen once from the caller's point of view, at the due instant, even across a worker crash — which is exactly why the run is at-least-once and the effect has to be idempotent under the client's key.
What this design never covered — raise it yourself
How the timing wheel works inside

A strong follow-up is how the bucketed index actually finds what's due in near-constant time. Name it as a hierarchical timing wheel — a ring of time buckets swept by a clock hand, with coarser overflow wheels for far-future tasks that cascade down as they near — a named, benchmarked primitive (Varghese and Lauck's design, as used in Kafka's Purgatory), and fence its bucket-index arithmetic as its own topic.

How a dispatcher survives its own death without double-firing

A sharp interviewer may separate the worker-side and dispatcher-side dedup. Name the dispatcher layer as a leader that synchronously notifies a quorum before and after each launch, with the scheduled fire time embedded in each job's identity so a new leader dedupes against completed launches — a distinct layer from the worker's idempotency key.

Retry and backoff on a task that fails to run

A natural what if the task itself errors invites the retry policy. Name it as retrying on an exponential backoff with jitter, heartbeat-extending the lease for a legitimately long task, and dead-lettering after a bounded number of attempts — and be honest that the specific backoff curve is general engineering practice here, not a single cited policy.

The short-fuse fast lane

A follow-up on latency for imminent tasks points at the bypass. Name it as jobs due within a short window (Dynein uses fifteen minutes) skipping the durable index straight to the dispatch queue, so the index only holds the genuinely-future set and imminent tasks fire without the index round-trip.

Go deeper — the primitives this design leaned on
  • The message queue (lease-and-ack dispatch)pop a due task, lease it to a worker with a visibility timeout, ack to delete or redeliver on expiry; the execution path this design commits to for at-least-once delivery without re-deriving it

Feedback on this problem →