Courses / System Design Interview / Chapter 20
Module 2 · Building Blocks · Chapter 20 of 39
Distributed Task Schedulers
Builds on Databases: Replication, Partitioning and Choosing a Store, Consensus, Leader Election and Coordination, Message Queues and Rate Limiters. Next up: Sharded Counters.
Part 1 · Work That Runs Later
Work that should not run inside a request
At 10:00 am on Class 10 results day, a state board hands an education messaging platform 2 million SMS to send, one per student. The platform runs them from a cron job on one server. At 10:04 the server's disk fills, the job dies at message 600,000, and nobody can say which students got their marks. Parents flood the board's helpline, and a small college's 50 exam reminders, booked for 10:05, sit stuck behind the board's batch. A task scheduler turns that morning into a non-event: every message is stored before it is promised, sent in small pieces that are retried when a machine dies, and shared fairly between customers.
Plenty of work does not belong inside a user's request: the board's 2 million SMS, monthly fee statements, or cancelling an unpaid exam-fee hold after 15 minutes. A task scheduler accepts descriptions of such work, called tasks or jobs, stores them safely, and makes sure each one runs at the right time on some machine, even when machines crash. The simplest scheduler is cron on one server, and results day showed what goes wrong with it:
| Cron on one server | What goes wrong |
|---|---|
| One machine | If it is down at 02:00, the nightly job silently does not run |
| No retries | A job that fails halfway stays failed until someone notices |
| No record | Nobody can tell which tasks ran, failed or are still waiting |
| One machine's capacity | 2 million messages cannot go out fast enough from one box |
A distributed scheduler fixes all four by splitting the problem in two: a durable store that remembers every task, and a pool of workers that can grow, shrink and crash without losing anything. We follow the education platform, and the board's 2 million messages, through the whole chapter.
Requirements and kinds of tasks
Before choosing any parts, agree on what the scheduler promises to the services that use it, such as the platform's result-SMS service:
- Never lose a task once the caller has been told it was accepted.
- Run on time, usually within a few seconds of the scheduled moment.
- Survive crashes: if a worker dies mid-task, the task runs again elsewhere.
- Scale out to millions of tasks a day by adding workers.
- Be fair: one big customer must not starve everyone else.
- Be visible: show what is waiting, running, done and failed.
Tasks come in four kinds:
| Kind | Example | What makes it hard |
|---|---|---|
| One-off, now | Send a welcome SMS when a college signs up | Low delay, and spikes after a marketing campaign |
| One-off, delayed | Cancel an unpaid exam-fee hold after 15 minutes | Millions waiting for their time; many are cancelled before they run |
| Recurring | Rebuild each college's attendance report every day at 02:00 | Create every run exactly once: no duplicates, no gaps |
| Long-running | Transcode a 2-hour recorded lecture | Progress tracking, and not blocking short tasks |
One design must serve all four, from 50 ms tasks to 2-hour ones.
Chains of dependent tasks, where B starts only after A succeeds, are called workflows. They build on everything in this chapter, so we keep them out of the core design; the box sketches how they extend it. First, the parts every scheduler has.
Part 2 · How a Scheduler Works
The parts
Whatever a scheduler is called, it has the same few parts, and tasks flow through them from left to right.
- Task API: services submit tasks with a type, a payload, a run time and a priority. A task counts as accepted only after it is safely written to the store.
- Task store: a replicated database and the source of truth for every task's state.
- Dispatcher: keeps finding tasks whose time has come and hands them to workers. Several dispatchers run at once, so none is a single point of failure.
- Workers: processes that run the actual code and report success or failure. When the backlog grows, you add workers.
id (pk)tenant_idtypepayloadpriorityrun_atstatusattemptslease_ownerlease_untilschedule_ididempotency_key (unique)id (pk)tenant_idcron_ruletime_zonenext_run_attask_template- tasks schedule_id → schedules
Claiming due tasks without double work
With several dispatchers looking for due tasks at once, the first danger is two of them grabbing the same one, like two clerks reaching for the same file. The database referees: a claim is a locked or conditional update that only one caller can win.
-- Claim up to 10 due tasks for worker 17. BEGIN; SELECT id FROM tasks WHERE status = 'pending' AND run_at <= now() ORDER BY run_at -- oldest due first; the index serves it LIMIT 10 FOR UPDATE SKIP LOCKED; -- rows locked by others are skipped UPDATE tasks SET status = 'leased', lease_owner = 'worker-17', lease_until = now() + interval '30 seconds', attempts = attempts + 1 WHERE id IN (/* ids from the SELECT */); COMMIT; -- Every few seconds, return expired leases to the pool. UPDATE tasks SET status = 'pending', lease_owner = NULL WHERE status = 'leased' AND lease_until < now();
PostgreSQL syntax; MySQL 8 also supports SKIP LOCKED. Priorities come from separate queues, each with its own table or index, not from this sort.
SKIP LOCKED lets ten dispatchers claim in parallel without waiting on each other's locks. The key column is lease_until: the task belongs to worker 17 only until that moment, and then the last statement makes it claimable again.
Finishing is conditional too. The worker marks the task done with WHERE id = ... AND attempts = 1, the attempt number it claimed. If its lease expired and another worker took over, attempts is now 2, so the late report changes nothing. Every task moves through four states:
| State | Meaning | Moves to |
|---|---|---|
| pending | Stored, waiting for its run time | leased |
| leased | One worker holds it until lease_until | done; pending again after a failure or an expired lease; dead |
| done | The worker reported success | Nothing: finished |
| dead | Failed too many times; parked for a human | pending, after someone fixes the cause |
Only leased is temporary by design: it always ends, either in a report from the worker or in an expired lease.
Index (status, run_at) so finding due tasks reads only the front of the line, already in order; sorting by priority too would force a sort of every due row. Claim in batches, and one database hands out thousands of tasks a second. But what if the worker holding a lease dies?
Leases and heartbeats
A worker can stop at any moment: the process crashes, the machine loses power, the container is evicted. A dead worker cannot report its own death, so the scheduler never hands out a task forever. It hands out a lease: "this task is yours for 30 seconds".
A worker that needs longer sends a heartbeat every 10 seconds or so, and each heartbeat pushes lease_until forward. If heartbeats stop, the lease runs out and the task becomes available to any other worker. Here is task 17, 500 of the board's 2 million SMS, when its worker dies halfway.
Predict before you watch: worker 1 crashes halfway through task 17, holding a 30-second lease. Who notices, and when does the task run again?
- Nobody notices the crash itself; the lease simply expires within 30 seconds, and the dispatcher hands the task to another worker.
A task is saved first: task 17 sends 500 of the board's 2 M result SMS
Read it as steps
The dispatcher leases a task, the worker crashes, the lease expires and another worker finishes the job.
Nobody had to detect the crash. The expiring lease is the failure detector, and it works the same way for a crashed process, a dead machine or a cut network cable. The price is waiting up to one lease length before the retry starts, so choosing that length is a trade-off:
| Lease length | Good | Bad |
|---|---|---|
| Short, like 10 s | A crashed worker's task restarts quickly | A long pause, such as garbage collection or a slow network, can expire a healthy worker's lease; more heartbeat traffic |
| Long, like 5 min | Fewer heartbeats and fewer false expiries | A crash delays the task by up to 5 minutes |
30 seconds with a heartbeat every 10 is a common middle: a crash costs at most half a minute, and a healthy worker must miss three heartbeats to lose its lease.
At least once, so make tasks idempotent
Look again at the crash. Worker 1 had sent 250 messages and died before saying so. The scheduler cannot tell a silent worker that did nothing from one that did everything and lost the network before reporting, so its only safe move is to run the task again. That makes the guarantee at-least-once: every task runs at least once, and sometimes more than once.
The scheduler cannot promise exactly-once execution on its own. What you can build is an exactly-once effect: run the task as often as needed, but make every repeat harmless. A task with that property is idempotent. Four common tasks, made safe to repeat:
| Task | Not idempotent | Idempotent version |
|---|---|---|
| Charge a subscription | Charge ₹499 | Charge ₹499 with key sub-123-2026-10; the payment service ignores a repeated key |
| Give a sign-up bonus | Add 100 coins | In one transaction, add 100 coins and set bonus_paid = true, only if it was false |
| Send result SMS | Send to all 500 numbers in the task | Key each message t17-<phone>, record the key once sent, skip recorded keys; only the message in flight can repeat |
| Write a daily report | Append the day's rows | Replace the whole output for that day |
The pattern in every row: record the effect under a key the task fixes in advance, and check that record before acting again. Four habits make retries cheap as well as safe:
- Keep tasks small: the board's 2 M result SMS go in as 4,000 tasks of 500, not one giant task. A crash then re-checks at most 500 keys and delays only those 500 messages by one lease.
- Retry with backoff: after a failure, set
run_at1 s later, then 2 s, 4 s, 8 s, plus random jitter so thousands of failed tasks do not retry in lockstep. - Cap the attempts: after, say, 5 tries the task moves to a dead-letter list for a human, instead of failing forever.
- Tell retryable errors from permanent ones: a timeout is worth retrying; an invalid phone number is not.
Part 3 · Design Choices
Priorities and separate queues
Retries keep tasks from being lost; the next risk is tasks running too late. On the platform, an OTP for a parent logging in to see results must go out in seconds, while a monthly attendance report can wait an hour. If both share one line, the board's 4,000 result tasks delay every OTP behind them. So schedulers keep separate queues by priority, and often by task type. A common split:
| Queue | Examples | Target delay | Workers |
|---|---|---|---|
| High | OTPs, payment confirmations | Under 1 s | A dedicated pool with spare capacity |
| Normal | Emails, thumbnails, notifications | Under 1 min | A shared pool |
| Low | Reports, clean-up, re-indexing | Within hours | Whatever capacity is left |
ExamplesOTPs, payment confirmations
Target delayUnder 1 s
WorkersA dedicated pool with spare capacity
ExamplesEmails, thumbnails, notifications
Target delayUnder 1 min
WorkersA shared pool
ExamplesReports, clean-up, re-indexing
Target delayWithin hours
WorkersWhatever capacity is left
Separate queues fix order, but strict priority can starve the low queue for as long as higher-priority work keeps arriving. A common fix is weighted serving: of every 10 tasks a worker takes, 7 come from high, 2 from normal and 1 from low, whenever those queues have work. Long tasks get their own pool too, so a 2-hour transcode never sits in front of a 50 ms SMS.
One slow item at the front of a shared line holds up everything behind it. Splitting lines by priority and by duration is the general cure, here and in many other systems.
Delays and recurring schedules
Priorities decide the order of tasks that are due; many tasks are not due yet. A delayed task is a task whose run_at is in the future; the index on run_at keeps it out of the way until it is due. Many delayed tasks never run at all: when the parent pays the exam fee, the "cancel unpaid hold" task is cancelled with one update.
A recurring task is stored once as a schedule, often written in cron syntax, and the scheduler creates one ordinary task for each run.
minute hour day month weekday meaning 0 2 * * * every day at 02:00 */5 * * * * every 5 minutes 30 9 1 * * 09:30 on the 1st of every month 0 10 * * 1-5 10:00, Monday to Friday
Store each schedule with its time zone, such as Asia/Kolkata, so 02:00 means 02:00 for that customer.
- Create each run exactly once: compute
next_run_atin the same transaction that creates a run, and give each run a unique key like(schedule_id, 2026-10-16 02:00)so a retried creation cannot make a second copy. - Decide what missed runs mean: if the scheduler was down at 02:00, run once on recovery, run every missed slot, or skip. A report wants one catch-up run; a reminder for a class that already started should be skipped.
- Spread the top of the hour: many jobs are written as
0 * * * *. Add a random offset of a few minutes to jobs that do not care about the exact minute. - Watch daylight saving: India has no clock changes, but customers in many other countries do, and a run scheduled inside the hour when clocks change can be skipped or happen twice.
Fairness and limits per tenant
The platform serves many customers, called tenants: 3,000 colleges and a few state boards. At 10:00 the board submits its 2 million result SMS as 4,000 tasks of 500, and a small college submits 50 exam reminders. With a single first-in, first-out line, the college waits behind all 4,000 tasks.
The cost is real. The platform's SMS contract allows 2,000 messages per second, so 2 million messages take 1,000 seconds, nearly 17 minutes, and the college's reminders arrive 17 minutes late although they need a fraction of a second of capacity. Four tools fix it:
- A queue per tenant, served in turn: the dispatcher takes a few tasks from each tenant's queue in round robin, so the small college's 50 go out in the first seconds.
- Weights for paid plans: a tenant on a bigger plan gets 3 turns for every 1 that a free tenant gets.
- Concurrency caps: no tenant may hold more than, say, 200 running tasks at once.
- Rate limits: a token bucket per tenant, as in the rate limiter chapter, plus one shared bucket that keeps all tenants together under the provider's limit.
A scheduler that can start 10,000 tasks a second can also flatten a database or get you blocked by an email provider. Put a rate limit on every external dependency, not only on every tenant.
Part 4 · Past One Database
Past one database
This is the part of the design that changes most with scale: a billion tasks break the single polled table, and storage, timing and hand-off must all be scaled together.
One table with SKIP LOCKED hands out thousands of tasks a second. Now the platform grows: it holds 1 billion class and fee reminders, and reminder times bunch up on the hour, so at 9:00 sharp, when first classes start, up to 100,000 fall due every second. Most go as app push notifications, whose providers together accept about 120,000 a second. At about 200 bytes each the tasks fill 200 GB, which one database can store; the trouble is the work. Claiming and finishing 100,000 tasks a second is 200,000 writes a second, far past one primary's tens of thousands, and every claim fights over the front of the same (status, run_at) index.
The fix is to stop searching for due work and file it instead. Give every task a bucket, its due time cut to the minute, and a shard, a hash of its id modulo 64. Each (bucket, shard) pair is one small list: (09:00, 17) holds every task due at 9:00 whose id hashes to 17. Finding due work becomes a key read, never a scan. A wide-column store keyed by (bucket, shard) fits well.
- 1Filethe API stores the task and adds its id to bucket (09:00, 17)
- 2Owneach of 16 dispatchers holds leases on 4 of the 64 shards
- 3Loada minute ahead, each dispatcher reads its shards' next bucket into memory
- 4Firein the task's second, the dispatcher publishes it to a queue
- 5Runa worker takes it from the queue, runs it and acknowledges
In numbers: the 9:00 minute holds up to 6 M tasks (100,000 × 60). Split 64 ways, each shard's bucket holds about 94,000, roughly 1,600 a second. A dispatcher that owns 4 shards fires about 6,250 a second and holds about 375,000 tasks for that minute, 75 MB at 200 bytes each. Spreading each minute over 64 shards also stops the 9:00 bucket from becoming one hot partition. What happens inside a dispatcher once a bucket is loaded?
Inside a dispatcher: the timing wheel
A loaded bucket is up to 94,000 tasks per shard, each due at a particular second. The dispatcher needs the earliest ones on time without sorting all of them over and over.
Loaded tasks wait in memory in time order. A min-heap keeps the earliest task on top, and each insert or removal costs a little more as the heap grows. A timing wheel is cheaper still: a ring of slots, one per second, like a clock face. Each task drops into the slot for its second, and a pointer moves one slot a second, firing everything in the slot it reaches. Adding or firing a task costs the same however many are waiting.
When a task's second arrives, the dispatcher does not run it. It publishes the task to a queue, as in the message queue chapter, and marks it queued in the store. Workers consume from the queue, and the queue's visibility timeout does the job the lease did in the one-table design: if a worker dies without acknowledging, the task reappears for another worker. Retries with backoff and the dead-letter queue come with the queue too.
For tens of millions of tasks and a few thousand fires a second, an in-memory store's sorted set does the job with far less machinery. Store each task id with its due time as the score. Dispatchers repeatedly take the ids whose score is at most now, removing them in the same atomic step (a short script), and publish them to the queue. Keep the full task in a durable database, so a lost sorted set can be rebuilt.
The timing wheel fires tasks on time while the dispatcher is healthy. The harder question is a dispatcher that is not quite dead.
When a dispatcher pauses
A dispatcher owns its shards through a lease held in a coordination service, as the coordination chapter explains: about 10 seconds long and renewed every few seconds. Only the owner fires a shard's tasks, so two dispatchers never fire the same bucket, and each new lease carries a higher fencing token that the store checks on every write.
A lease alone cannot protect the store. A dispatcher frozen by a long pause cannot know that time passed, so it may wake up and write as if it still owned the shard. The fencing token moves the check to the place where the damage would happen:
Watch for token 41: dispatcher A's late write is refused once B holds 42
Read it as steps
Shard 17 passes from dispatcher A (token 41) to dispatcher B (token 42) during A's pause, and the store refuses A's late write.
The store keeps the highest token it has seen per shard and refuses anything lower, so a stale owner can never move a shard's progress backwards and make B skip or repeat a second of tasks. A may still have published a task in its last moments before the pause; the worker's idempotency key absorbs that duplicate. Two more cases complete the failover story:
- A dispatcher dies: its shard leases expire within about 10 seconds; other dispatchers take the shards, reload the current bucket and fire anything overdue at once. A task published just before the crash but not yet marked
queuedgoes out twice, so workers stay idempotent. - A task arrives late: one created at 08:59:58 for 09:00:30 misses the load a minute ahead. The API hands tasks due within the next minute straight to the shard's owner, and as a safety net each dispatcher re-reads its current bucket every few seconds; anything already due goes straight to the queue.
Part 5 · When Things Go Wrong
What goes wrong
A scheduler's whole job is keeping promises while machines fail, so walk through the failures one at a time.
| Problem | What happens | Fix |
|---|---|---|
| A dispatcher dies | Due tasks are not handed out | Run several. With one table, any of them can carry on; with sharded buckets, its shard leases expire and another dispatcher takes over within seconds |
| A worker dies | Its tasks stall | Leases expire and the tasks are retried elsewhere |
| A long pause, not a crash | The lease expires while the old worker is still running, so two workers run the task | Idempotent tasks, plus a fencing token: each lease carries a rising number, such as the attempt count, and writes with an older number are rejected (the coordination chapter covers fencing in depth) |
| A poison task | It crashes every worker that picks it up | Cap the attempts, then move it to the dead-letter list |
| A backlog after an outage | Millions of overdue tasks fire at once | Drain at a controlled rate, highest priority first |
| The store slows down | Claims get slower as the table grows | Index (status, run_at), claim in batches, archive finished tasks; at billions of tasks, move to due-minute buckets split into shards |
| Clocks disagree | Leases expire early or late | Compare lease times using the store's clock, not each worker's |
The common thread: assume any machine can stop at any instant, and rest every guarantee on two things the scheduler controls, the durable store and the lease clock.
Track the age of the oldest due task per queue. If it climbs, tasks are running late, whatever the cause: too few workers, a slow store, a stuck tenant or a poison task.
Part 6 · Interview kit
The 45-minute answer
Here is one way to spend the 40 design minutes of a 45-minute interview on "design a scheduler that sends a billion reminders".
| Minutes | What you say and draw | Key number or decision |
|---|---|---|
| 0–5 | One-off and recurring reminders, delays from seconds to months, cancel, status; at-least-once, on time within seconds. | Never lose an accepted task; fire within 5 s |
| 5–8 | 1 B pending at about 200 B; 100,000 due a second at 9:00; a claim and a finish per task. | 200 GB fits one machine; 200,000 writes/s does not |
| 8–13 | Create with an idempotency key, cancel, status. Tasks filed by (due-minute bucket, shard); schedules for recurring ones. | Shard = hash(id) mod 64 |
| 13–22 | API, task store, dispatchers, queue, workers, push and SMS providers; one reminder from create to delivered. | Acknowledge after a replicated write |
| 22–37 | Shard leases, minute-ahead load into a timing wheel, publish to a queue, failover, fencing tokens, idempotent sends. | 16 dispatchers × 4 shards ≈ 6,250 fires/s each |
| 37–40 | The 9:00 spike, provider limits, age of the oldest due task, the sorted-set version for small scale. | Sorted set below tens of millions of tasks |
If they push on scale, go deep on buckets, shard leases and the timing wheel. If they push on correctness, go deep on leases, fencing tokens and idempotency keys. If they mention many customers, add per-tenant queues and rate limits.
- Say
- "Tasks are filed by due minute into 64 shards, so finding due work is a key read, not a scan of a billion rows."
- Say
- "A dead worker delays its task by at most one 30-second lease, and a fencing token stops a paused worker's late write."
- Say
- "Execution is at-least-once, so each send carries an idempotency key; a retry repeats at most the one message in flight."
- Avoid
- "A cron job on one server runs everything." One point of failure, no retries, no record.
- Avoid
- Promising exactly-once execution without saying how repeats are made harmless.
Wrap-up
Here is the chapter on one page.
| Term | Meaning |
|---|---|
| Lease | Time-limited ownership of a task that expires unless renewed |
| Heartbeat | A worker's regular signal that extends its lease |
| Idempotent task | A task whose repeats have no extra effect |
| Fencing token | A rising number that lets storage reject a stale writer |
| Dead-letter list | Where tasks go after too many failed attempts |
| Due-minute bucket | All tasks due in one minute, split into shards |
| Timing wheel | A ring of time slots that fires each slot's tasks in turn |
Next comes Sharded Counters, where the problem flips from work that runs later to one number that thousands of writers update at once.
Part 7 · Check yourself
Quick check
Q1Why does a scheduler hand out leases instead of assigning tasks to workers for good?
Think it through, then check.
- A crashed worker cannot report its own death; a lease expires by itself, so the task returns to the pool with no detector needed.
- Heartbeats extend it for long tasks; the price is up to one lease of delay after a crash.
Q2Due tasks go to a queue with a 30-second visibility timeout, and a lecture transcode takes 2 minutes. Using the message queue chapter, what goes wrong, and what fixes it?
Think it through, then check.
- After 30 s the message reappears, so a second worker starts the same transcode, then a third.
- Set the timeout above the p99 run time, or let the worker extend it as it works, like a heartbeat; better, give long tasks their own queue.
Q3A college's nightly attendance report shows stale numbers. Worker A took task 88, paused 40 s, then wrote its result 10 s after worker B finished the same task. What happened, and what stops it?
Think it through, then check.
- A's 30-second lease expired during the pause, so task 88 went to B; A woke and overwrote B's newer result.
- A paused process cannot know time passed, so the check belongs where data is written.
- Use a fencing token: A holds 7, B holds 8, and the store rejects any write numbered below the highest it has seen.
Q4One college runs its own reminders, about 50,000 a day. Would you build buckets, shard leases and timing wheels? If not, what, and when would you change?
Think it through, then check.
- No. 50,000 a day is under 1 a second on average, and even a 100× spike at 9:00 is about 60 a second.
- One PostgreSQL table indexed on
(status, run_at)withSKIP LOCKEDclaims handles it far more simply. - Move to buckets and shards at thousands of claims a second or hundreds of millions of rows.
Q5Dispatchers were down from 7:00 to 7:05 while 3 M reminders came due (10,000 a second). Push providers accept 120,000 a second, and new reminders keep arriving at 10,000 a second. How long until reminders are on time, and what goes first?
Think it through, then check.
- Spare capacity is 120,000 − 10,000 = 110,000 a second, so the backlog clears in 3 M ÷ 110,000 ≈ 27 seconds.
- Send by priority; drop reminders now wrong, such as "class starts in 2 minutes".
- Drain at the providers' limit, or rejected sends and retries make it worse.