Design a Job Scheduler
Run millions of delayed and recurring jobs on time: time-bucketed storage, leases, at-least-once execution, worker failure, idempotent jobs and cron.
On this page
"Send this user a reminder in 24 hours." "Retry this failed upload in 5 minutes." "Build the sales report every day at 06:00 in Tokyo." Almost every product has work that must happen later, and every team that writes its own timer on one server eventually loses jobs when that server restarts. A shared job scheduler takes the job, stores it safely, and makes sure some worker runs it close to the right time.
Interviewers like this question because "run it later" hides three hard problems: finding the few jobs due right now among millions waiting, handing each one to exactly one worker when workers crash and pause, and being honest that "exactly once" is something you build out of retries and idempotent jobs. The steps follow A Framework for Any Design.
Clarify the problem
Design a distributed job scheduler.
What kinds of jobs: one-off jobs at a time or after a delay, recurring cron-style schedules, or both? And does the scheduler run the job's code, or hand it to the owning team's workers?
Both kinds. Teams run their own workers; you store, schedule and dispatch.
How many jobs, and how precise must the timing be?
About 100 million job runs a day and a million recurring schedules. Starting within a few seconds of the due time is fine.
Then I'll promise: an accepted job is never lost, it starts within 10 seconds of its due time under normal load, and it runs at least once. I'll say plainly that it can run twice, so job handlers must be idempotent. Workflows with dependencies between jobs are out of scope.
- Functional: schedule a one-off job at a time or after a delay; create recurring schedules from a cron expression and time zone; cancel jobs; see a job's status and attempts; retry failures with backoff up to a limit, then park them for a human.
- Non-functional: no accepted job is lost (durable, replicated storage); jobs start within 10 seconds of due time under normal load; at-least-once execution; no single machine whose loss stops scheduling.
- Out of scope: running job code ourselves, job dependencies and workflows, priorities beyond a few named queues.
Estimate the scale
- Job runs: 100 million ÷ 86,400 ≈ 1,200 a second on average.
- Writes: each run is created, enqueued, claimed and finished: about 4 writes, so about 4,800 a second. Too many for comfort on one database primary at peak; this points to sharding.
- Waiting jobs: suppose 10% of jobs are scheduled about a week ahead and the rest within the hour. 100 million × 0.1 × 7 days = 70 million waiting at once, at about 1 KB each, 70 GB. Keeping 7 days of finished jobs for debugging adds 100 million × 7 × 1 KB = 700 GB.
- The burst: if 20% of the million schedules say "every hour, on the hour", then 200,000 jobs are due in the same second at every :00.
Workers can run 1,800 jobs a second in total. At 10:00:00, 200,000 hourly jobs become due at once. Ignoring other traffic, how long until the last one starts?
- 200,000 jobs due at the same second
- The worker fleet runs 1,800 jobs a second at full speed
Show the working
- 200,000 ÷ 1,800 per second ≈ 111 seconds, almost 2 minutes
Estimate 111 seconds (anything from 40 seconds to 5 minutes is a good answer)
That breaks the 10-second promise for most of the burst. Starting them all within 10 seconds needs 20,000 a second, about 11 times the fleet, idle for the rest of the hour.
Where 1,800 comes from: at 1,200 jobs a second and about 2 seconds per job, 1,200 × 2 = 2,400 jobs run at any moment. Workers run 10 jobs each, so 240 workers are fully busy; with 50% headroom you run 360, which can do 360 × 10 ÷ 2 = 1,800 jobs a second.
Write the calls a team uses to schedule, cancel and inspect a job, and the calls its workers use to get and finish work. Do workers get pushed jobs or pull them?
Compare with a model answer
Create a job, create a schedule, cancel, get. Workers pull: they ask for work, get a lease, send heartbeats while running, and report the result with the attempt number they were given.
Define the API
POST /jobs
body: { "queue": "emails", "payload": {...}, "run_at": "2026-11-15T10:05:20Z",
"max_attempts": 5, "timeout_s": 60, "idempotency_key": "remind-u42-d15"? }
201 { "job_id": "j_a8f3c1" }
400 run_at too far in the future, or payload over 64 KB
POST /schedules
body: { "queue": "reports", "cron": "0 6 * * *", "time_zone": "Asia/Tokyo",
"on_overlap": "skip", "on_missed": "run_once" }
201 { "schedule_id": "s_311" }
DELETE /jobs/{id} 200 cancelled 409 already running
GET /jobs/{id} 200 { "status": "running", "attempt": 2, "last_error": "..." }
Worker side:
POST /queues/{q}/lease 200 [ { "job_id", "attempt", "lease_until", "payload" } ]
POST /jobs/{id}/heartbeat body: { "attempt": 2 } 200, or 409 lease lost
POST /jobs/{id}/complete body: { "attempt": 2, "ok": true } 200, or 409 lease lostThe decision to argue is pull, not push. If the scheduler pushed jobs at workers, it would need to know each worker's capacity, and a slow team's workers would back up into the scheduler. When workers pull, each takes only what it can run, and a backlog simply waits in the queue. The attempt number in every worker call is what lets the scheduler reject a worker that has lost its lease.
Design the job record and its key. The hot query is "which jobs are due now?" among 70 million waiting. How do you make that query small?
Compare with a model answer
Two records. The job itself, found by its id. And a small "due" index keyed by (shard, minute bucket of the run time), holding only job ids. The dispatcher for a shard reads only the current minute's partition, a few thousand rows, instead of searching one huge index.
Model the data
jobs -- one row per job, found by id
job_id VARCHAR(64) PRIMARY KEY -- random, or "s_311@2026-11-15T06:00" for a schedule run
shard SMALLINT -- hash(job_id) % 64
queue VARCHAR(64)
payload BLOB -- up to 64 KB; larger goes to blob storage
run_at TIMESTAMP
status VARCHAR(12) -- scheduled | enqueued | running | succeeded | cancelled | dead
attempt INT
lease_until TIMESTAMP NULL -- indexed for running jobs, for the lease sweep
schedule_id VARCHAR(32) NULL
due -- the timer: which ids are due when
shard SMALLINT
bucket TIMESTAMP -- run_at rounded down to the minute
run_at TIMESTAMP
job_id VARCHAR(64)
PRIMARY KEY ((shard, bucket), run_at, job_id)
schedules
schedule_id, cron, time_zone, queue, payload, on_overlap, on_missed, next_run_atA job and its due rows hash to the same shard, so creating a job is one local transaction across both tables. The access patterns are: insert a job and its due row; read one (shard, bucket) partition of due in time order; update one job's status with a condition ("only if attempt is still 2"); drop whole old buckets. A retry or a new run time just adds another due row. The dispatcher checks the job's status before enqueuing, so a due row left over from a cancelled or rescheduled job is skipped. Each dispatcher also records, per shard, the last due row it handled, so after a restart or a takeover it resumes from there.
Any store with partitions, sorted keys inside a partition and per-row conditional writes fits: a sharded relational database with the due table partitioned by time, or a wide-column store such as Cassandra. I would pick the sharded relational database because claims and leases are conditional updates, which it does cheaply; the wide-column store wins if write volume grows far past this.
Sketch the boxes: where jobs are stored, what finds due jobs, how they reach workers, and how you make sure two scheduler machines do not both scan the same shard.
Compare with a model answer
An API writes jobs to the sharded job store. Dispatchers each own a set of shards, granted by a coordination service, and scan the current bucket every second. Due job ids go onto a ready queue per job type. Workers pull ids from it and claim, heartbeat and finish jobs in the store.
High-level design
Step through one delayed job, including a worker that stalls halfway.
- status
- scheduled
- attempt
- 0
1At 09:05 a service asks for a reminder at 10:05:20. The API hashes the new id to shard 17 and writes the job, plus a due row in partition (17, 10:05), on three replicas before answering. Accepted means durable.
- status
- scheduled
- attempt
- 0
2Dispatcher D2 holds a lease from the coordinator on shards 16 to 31, renewed every few seconds. Normally only D2 scans shard 17, so each due job is found once.
- status
- enqueued
- attempt
- 0
3Every second D2 reads the current minute's partitions for its shards. At 10:05:20 the job is due: D2 moves it from
scheduledtoenqueuedwith a conditional update, then puts its id on theemailsqueue.- status
- running
- attempt
- 1
- lease until
- 10:05:51
4Worker W3 pulls the id and claims the job in the store: status running, attempt 1, lease until 10:05:51. It must heartbeat every 10 seconds to keep the lease.
- status
- running
- attempt
- 1
- lease until
- 10:05:51
5W3 sends the email, then freezes in a 40-second garbage-collection pause before it can report. No heartbeats arrive.
- status
- enqueued
- attempt
- 1
6At 10:05:51 the lease has expired. D2's sweep of running jobs with an expired lease finds it, sets it back to
enqueuedand puts the id on the queue again. From the scheduler's side, W3 might be dead.- status
- running
- attempt
- 2
- emails sent
- 2
7Worker W5 claims it as attempt 2 and sends the email. The user now has two reminders, because nothing can tell whether W3 finished its side effect.
- status
- running
- attempt
- 2
- emails sent
- 2
8W3 wakes and reports done for attempt 1. The store only accepts the current attempt, so it answers 409. The attempt number is a fencing token: it stops stale writes, not stale side effects.
- status
- succeeded
- attempt
- 2
9W5 reports done for attempt 2 and the job is marked succeeded. The fix for the double email belongs in the job: send with the idempotency key
remind-u42-d15so the email service drops the repeat.
Deep dives
Finding due jobs
Seventy million jobs wait; about 1,200 become due each second. The scheduler must find those 1,200 cheaply, every second, forever.
| Option | Good at | Bad at | Pick it when |
|---|---|---|---|
| One table, index on run_at | Simple. Pollers run WHERE run_at <= now() … FOR UPDATE SKIP LOCKED and several can work at once. | One database holds everything. Finished rows bloat the index unless cleaned, and 4,800 writes a second, before any peak, strains one primary. | Up to a few hundred jobs a second. Most companies should start here. |
| Time-bucketed partitions Usual default | Each scan reads one small (shard, minute) partition. Old due buckets are dropped whole. Shards spread writes. | Needs shard ownership so two dispatchers do not scan the same partition. Precision is limited by the scan interval. | Millions of jobs a day with a few seconds of tolerance. This design. |
| In-memory timer wheel | Jobs fire within milliseconds of their time. Dispatchers load the next few minutes from the store into memory. | Memory is lost on a crash, so the next owner must reload the window; more moving parts. | Sub-second precision matters, on top of buckets for durability. |
| The queue's delayed delivery | No scheduler to build: the message becomes visible at its time. | Delays are usually capped, and you cannot easily list, change or cancel a waiting job. | Short delays such as retry backoff, where nobody needs to inspect the job. |
With 64 shards, the 200,000-job burst at :00 lands as 200,000 ÷ 64 ≈ 3,125 rows per partition: one quick read each. A dispatcher that falls behind does not skip anything; it reads the older buckets it missed first, so a slow minute delays jobs but never loses them.
Leases and at-least-once execution
A worker that claims a job gets a lease: the job is theirs until lease_until, extended by heartbeats. If the worker dies, the lease runs out and the job is handed out again. That is the whole recovery story, and it is why execution is at least once: a job is never stuck forever, but a worker that only looked dead may still finish its work.
Choosing the lease length is a trade-off. A short lease (30 seconds here) recovers quickly from a crash but re-runs jobs on any worker that pauses for longer. A long lease means fewer duplicates but a dead worker's job waits longer. Heartbeats let you keep the lease short for jobs that run for an hour.
Shard ownership uses the same idea one level up. Each dispatcher holds a lease on its shards from a coordination service built on consensus (see consensus and leader election). If a dispatcher stalls, another takes its shards when the lease expires. The stalled one does not know yet, so for a moment two dispatchers can scan one shard. That is safe because every step is conditional: only one of them can move a job from scheduled to enqueued, and a duplicate id on the queue finds the job already running when a worker tries to claim it, and is dropped.
The exactly-once illusion
"Exactly once" in this system can only mean exactly-once effect. The tracer shows why: W3 sent the email, then paused, and nothing outside W3 could know whether the send happened. Any scheduler that re-runs jobs from dead workers must sometimes re-run jobs from live ones. Say this plainly in the interview, then give the tools:
- Idempotent handlers. Pass an idempotency key derived from the job (job id, or the caller's own key) to every downstream call, as in idempotency and retries.
- Fencing tokens. Every write the job makes carries its attempt number, and the target rejects older attempts. The store does this for job status; a job can do the same for its own outputs.
- Natural idempotency. "Set the report for 2026-11-15 to this file" can safely run twice; "append a row to the report" cannot.
Cron schedules versus one-off jobs
Store a schedule as a definition, not as a million future jobs. When an occurrence is claimed, create the next one, with an id built from the schedule id and its scheduled time. Insert-if-absent then makes a repeat harmless: two dispatchers trying to create the 07:00 run produce one row. Three policies need a deliberate answer:
- Overlap: the 06:00 run is still going at 07:00. Skip, queue, or run both. Default to skip for reports.
- Missed runs: the scheduler was down from 02:00 to 05:00. Run once to catch up, run all three, or skip. Default to once.
- Time zones: compute the next time in the schedule's own zone. On a daylight-saving change, 02:30 can happen twice or not at all, so decide which, and test it.
For the :00 burst, let schedules say "hourly, any minute" and pick a stable minute from a hash of the schedule id. The 200,000 hourly jobs then arrive at about 200,000 ÷ 3,600 ≈ 56 a second instead of all at once, and the fleet you already have keeps the 10-second promise.
Bottlenecks and failures
At 10x, a billion runs a day, about 12,000 a second: add shards to the store (64 to 512), split dispatcher ownership finer, and expect the burst to dominate. Ready queues are per job type, so one team's flood of jobs does not delay another team's; the team that floods its own queue scales its own workers.
| When this dies | What happens | What you do |
|---|---|---|
| A worker | Its leases run out after up to 30 seconds. | The sweep re-enqueues the jobs; another worker runs them as the next attempt. |
| A dispatcher | Its shards are not scanned until its shard lease expires. | Another dispatcher takes the shards and reads the buckets it missed. Jobs start late, none are lost. |
| The coordinator | No shard leases can be renewed or moved. | Run it on 3 or 5 nodes with consensus. Dispatchers stop scanning when their lease lapses. A paused dispatcher may overlap with the new owner for a moment; the conditional updates make that harmless. |
| A job store shard | Jobs in that shard cannot be created, claimed or finished. | Replicas take over; the API sends new jobs to other shards by picking a different id. |
| A ready queue | Enqueued ids may be lost. | Sweep jobs stuck in enqueued for over a minute and enqueue them again. The store is the truth. |
What a strong answer sounds like
| Level | What the answer does |
|---|---|
| Junior | Stores jobs in a database and has workers poll for due ones; with prompting, handles a worker that crashes. |
| Mid | Uses leases and heartbeats, explains at-least-once, partitions jobs by time and handles retries with backoff. |
| Senior | Sizes the :00 burst and changes the schedule semantics to spread it, owns shards with leases from a consensus service, uses attempt numbers as fencing tokens, and is precise that exactly-once is an effect built from idempotent jobs. |
Can you guarantee each job runs exactly once?
Yes. I'd take a distributed lock on the job id before running it, so only one worker can have it.
Can you guarantee each job runs exactly once?
Not execution, no. If a worker goes quiet, I can't tell dead from paused, so I re-run the job, and sometimes both finish. What I guarantee is at least once, plus tools for exactly-once effect: every attempt gets a number that the store uses as a fencing token, and every job gets a stable key to pass to the services it calls.
So who is responsible for duplicates?
Shared: the scheduler keeps duplicates rare and visible with attempt numbers, and job owners make their handlers idempotent. I'd put that in the scheduler's documentation on day one.
Follow-up questions
How do you cancel a job that is due in a minute?
Set its status to cancelled with a conditional update that only succeeds while it is still scheduled or enqueued. A worker's claim checks status, so a cancelled id that is already in the queue is dropped. Once running, cancellation becomes a request the job may honour.
How do retries work?
A failed attempt gets a new run time, now plus exponential backoff with jitter, and a new due row in the matching bucket. After max_attempts the job goes to a dead-letter status for a human, so one broken job cannot retry forever.
One team schedules 10 million jobs for midnight. What protects everyone else?
Per-queue rate limits on dispatch and separate ready queues per team. Their jobs start late; everyone else's keep their 10-second promise.
How would you add priorities?
Give each priority its own ready queue and let workers pull from high before low. Keep priority out of the store's key, so finding due jobs stays one partition read.
How do you know the scheduler is healthy?
Track scheduling lag (start time minus due time) per queue, the age of the oldest due job not yet enqueued, lease expiries per minute and dead-letter counts. Lag is the number users feel; alert on it.
After your attempt, tick what you really did. Be strict: "I thought about it" is not "I said it out loud".