Skip to lesson
‹

Courses / System Design Interview / Chapter 20

Module 2 · Building Blocks · Chapter 20 of 39

Distributed Task Schedulers

16 sections · ~32 min✓ Fact-checked

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 serverWhat goes wrong
One machineIf it is down at 02:00, the nightly job silently does not run
No retriesA job that fails halfway stays failed until someone notices
No recordNobody can tell which tasks ran, failed or are still waiting
One machine's capacity2 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:

KindExampleWhat makes it hard
One-off, nowSend a welcome SMS when a college signs upLow delay, and spikes after a marketing campaign
One-off, delayedCancel an unpaid exam-fee hold after 15 minutesMillions waiting for their time; many are cancelled before they run
RecurringRebuild each college's attendance report every day at 02:00Create every run exactly once: no duplicates, no gaps
Long-runningTranscode a 2-hour recorded lectureProgress 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.
tasksid (pk)tenant_idtypepayloadpriorityrun_atstatusattemptslease_ownerlease_untilschedule_ididempotency_key (unique)
schedulesid (pk)tenant_idcron_ruletime_zonenext_run_attask_template

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.

sql
-- 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:

StateMeaningMoves to
pendingStored, waiting for its run timeleased
leasedOne worker holds it until lease_untildone; pending again after a failure or an expired lease; dead
doneThe worker reported successNothing: finished
deadFailed too many times; parked for a humanpending, 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.

Keep polling cheap

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.
Animated sceneA worker dies, and its task survives0:22

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 lengthGoodBad
Short, like 10 sA crashed worker's task restarts quicklyA long pause, such as garbage collection or a slow network, can expire a healthy worker's lease; more heartbeat traffic
Long, like 5 minFewer heartbeats and fewer false expiriesA 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:

TaskNot idempotentIdempotent version
Charge a subscriptionCharge ₹499Charge ₹499 with key sub-123-2026-10; the payment service ignores a repeated key
Give a sign-up bonusAdd 100 coinsIn one transaction, add 100 coins and set bonus_paid = true, only if it was false
Send result SMSSend to all 500 numbers in the taskKey each message t17-<phone>, record the key once sent, skip recorded keys; only the message in flight can repeat
Write a daily reportAppend the day's rowsReplace 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_at 1 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:

QueueExamplesTarget delayWorkers
HighOTPs, payment confirmationsUnder 1 sA dedicated pool with spare capacity
NormalEmails, thumbnails, notificationsUnder 1 minA shared pool
LowReports, clean-up, re-indexingWithin hoursWhatever capacity is left
High

ExamplesOTPs, payment confirmations

Target delayUnder 1 s

WorkersA dedicated pool with spare capacity

Normal

ExamplesEmails, thumbnails, notifications

Target delayUnder 1 min

WorkersA shared pool

Low

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.

Head-of-line blocking

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.

text
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_at in 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.
Limits protect the systems you call

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

Core deep dive

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.

From a stored reminder to a running task
  1. 1Filethe API stores the task and adds its id to bucket (09:00, 17)
  2. 2Owneach of 16 dispatchers holds leases on 4 of the 64 shards
  3. 3Loada minute ahead, each dispatcher reads its shards' next bucket into memory
  4. 4Firein the task's second, the dispatcher publishes it to a queue
  5. 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.

The light version: a sorted set

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:

Animated sceneA paused dispatcher loses its shard0:20

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 queued goes 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.

ProblemWhat happensFix
A dispatcher diesDue tasks are not handed outRun 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 diesIts tasks stallLeases expire and the tasks are retried elsewhere
A long pause, not a crashThe lease expires while the old worker is still running, so two workers run the taskIdempotent 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 taskIt crashes every worker that picks it upCap the attempts, then move it to the dead-letter list
A backlog after an outageMillions of overdue tasks fire at onceDrain at a controlled rate, highest priority first
The store slows downClaims get slower as the table growsIndex (status, run_at), claim in batches, archive finished tasks; at billions of tasks, move to due-minute buckets split into shards
Clocks disagreeLeases expire early or lateCompare 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.

The one metric to watch

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".

MinutesWhat you say and drawKey number or decision
0–5One-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–81 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–13Create with an idempotency key, cancel, status. Tasks filed by (due-minute bucket, shard); schedules for recurring ones.Shard = hash(id) mod 64
13–22API, task store, dispatchers, queue, workers, push and SMS providers; one reminder from create to delivered.Acknowledge after a replicated write
22–37Shard 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–40The 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.

TermMeaning
LeaseTime-limited ownership of a task that expires unless renewed
HeartbeatA worker's regular signal that extends its lease
Idempotent taskA task whose repeats have no extra effect
Fencing tokenA rising number that lets storage reject a stale writer
Dead-letter listWhere tasks go after too many failed attempts
Due-minute bucketAll tasks due in one minute, split into shards
Timing wheelA 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.

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.

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.

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.

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.

Next chapter · 21 of 39Sharded Counters~26 min · 1 animated scene