Library
30 min read

Design a distributed job scheduler

"Send this reminder at 9am." "Rebuild the report every night at 2." "Retry this export in ten minutes." Every product grows a pile of work that has to happen later, at a time, and it has to happen even when the machine that was going to do it has died.

Watch one run go from a schedule to done.

ServicesScheduler APIJob storePartition scannerRun queueWorkersTarget service

Submit. A service asks for a job: call this endpoint every day at 02:00, Asia/Kolkata.

Two promises pull against each other here. Every due run must start, even if a worker or a scanner dies, which pushes you towards running things again. And a run should have its effect once, which pushes you away from running things again. The design is mostly the machinery that lets you keep the first promise without breaking the second.

Where this already exists

This page builds a shared scheduler service, the kind a company runs so that every team does not write its own cron box. The numbers are interview assumptions. You have probably used a small version: Kubernetes CronJob runs a job on a cron schedule and makes you choose a concurrencyPolicy (allow overlapping runs, forbid them, or replace the old one) and a startingDeadlineSeconds for runs that start late. Quartz, the Java scheduler, calls the late run a misfire and makes you pick what to do with it. Both are the decisions this page is about.

A few words used all through this page:

  • A job is the definition: what to call, with what, and when. A run is one execution of a job at one scheduled time.
  • A lease is a claim with an expiry. A worker holding a run's lease is the one allowed to run it, until the lease runs out.
  • A fence token is a number that goes up every time a lease is handed out, so storage can tell an old holder from the current one.
  • A misfire is a run whose time passed while nothing was able to start it.

Step 1: Understand the problem#

Ask what "exactly once" means to them before you draw anything. The answer decides whether you are building a scheduler or a distributed transaction system, and you want to be building a scheduler.

You askThey sayWhat it settles
One off jobs, recurring jobs, or both?Both. "Run at 9am on Friday" and cron schedules like "every day at 02:00".A recurring job is a stream of runs, each with its own id made from the job and the scheduled time. That id is what makes creating a run twice harmless.
How many runs, and when?About 50 million a day, with big spikes on round times like midnight and 9am.The average is small. The busiest minute is the design load, and a scheduler that is fine at the average falls over at 09:00.
How late may a run start?A few seconds is fine. Some jobs say "any time in this five minute window".A scanner that looks for due runs every second or so, not a timer per job, and a window field that lets the system spread a spike.
Must a job run exactly once?It must never be skipped, and running twice should do no harm.At least once execution, plus a run id the target uses as an idempotency key, plus fence tokens for storage writes. Exactly once execution is not on offer, and the design says so.
What happens to runs missed while the scheduler was down?Depends on the job.A misfire policy per job: skip, run once to catch up, or run every missed slot.
Do jobs run inside the scheduler, or call something else?They call a target: an HTTP endpoint or a queue topic owned by the team.The scheduler owns time and delivery, not the work. It cannot make a target's side effects happen once, only hand it what it needs to make them so.

What you are building, and what you cut#

Functional requirements
What the system does.
  • Create, pause and delete one off and recurring jobs. The definition, stored once and changed rarely.
  • Start every due run within seconds of its time. The core promise, and the part that has to survive failures.
  • Retry failed runs with backoff, then give up visibly. Targets fail, and a run that silently vanishes is the worst outcome.
  • A history of runs per job. The first thing anyone asks when a report did not arrive.
Non-functional requirements
How well it has to do it. The numbers you commit to.
  • 50 million runs a day, and a busiest minute far above the average.
  • A due run starts within about 5 seconds, or within its window.
  • No run is skipped because a machine died. Running twice is allowed, with a run id to dedupe on.
  • Survives the loss of any scanner, worker or zone.
Out of scope
What you cut, and say so out loud.
  • Workflows with many dependent steps. That is a workflow engine, and it sits on top of this.
  • Running the job's code. Targets run their own code; the scheduler calls them.
  • Sub second precision. Nobody needs a reminder at 09:00:00.250.
  • Per tenant quotas and billing. Real, and they do not change the core.

Back of the envelope#

A small version first. 50 million runs a day over 86,400 seconds is about 580 a second. If 5% of a day's runs are set for 09:00 exactly, that is 2.5 million runs in one minute, about 42,000 a second, more than 70 times the average. The window slider shows what letting those jobs start any time in the next few minutes does. (The back of the envelope chapter covers the method.)

The average is not the load
50 million
5%
1 minutes
Average starts per second50,000,000 / 86,400 = 579
Runs set for the busiest minute50,000,000 x 5% = 2,500,000
Peak starts per second2,500,000 / (1 x 60) = 41,667
Peak against the average72x
41,667 a second
at the busiest minute, which is the number to design for

Size for the peak, not the average. A window of 1 minute turns the spike into 41,667 starts a second. Most jobs set for 09:00 do not care whether they start at 09:00 or 09:03, and asking for the window is the cheapest capacity you will ever buy.

Step 2: Propose the high level design#

The core entities
  • JobWhat to call, the payload, the schedule and time zone, the retry and misfire policies. Changed rarely.
  • RunOne execution of a job at one scheduled time. Its id is the job id plus that time.scheduledqueuedrunningsucceededfaileddead
  • LeaseA worker's claim on a run, with an expiry and a fence token. Renewed by heartbeat while the run is going.
  • PartitionA slice of all runs, by hash of the job id. One scanner owns each partition at a time.
Never breaks: Every due run starts, and two workers never both hold the same run's lease.

The API#

Four calls. The one that matters most is the one teams never think about: what a target receives.

POST/v1/jobs
{
  "schedule": "0 2 * * *",
  "timezone": "Asia/Kolkata",
  "target": "https://reports.internal/rebuild",
  "window": "5m",
  "misfire": "run_once",
  "retry": { "max": 5, "backoff": "exponential" }
}
returns 201 { jobId, nextRunAt: "2026-10-28T20:30:00Z" }
Why: The next run comes back in UTC, so the caller can see at once how their schedule was read. A time zone is required, never assumed, because "02:00" means nothing without one.
POST/v1/jobs/{id}:pause
returns 200
Why: Pause, not delete, is what an on-call engineer reaches for at 3am. Paused jobs keep their history and come back with their misfire policy applied.
GET/v1/jobs/{id}/runs?limit=20
returns 200 { runs: [{ runId, scheduledFor, state, attempt, startedAt, error }] }
Why: The run history. When a report did not arrive, this answers "did it run, when, and what did the target say" without anyone opening logs.
POST{target}, called by the scheduler
Idempotency-Key: job_88213@2026-10-28T20:30:00Z
X-Fence-Token: 41
{ ...payload }
returns 2xx done, 4xx do not retry, 5xx or timeout retry
Why: The run id travels as an idempotency key, so a target that sees the same run twice can do the work once. The fence token lets a target that writes to storage reject a worker whose lease has already passed to someone else.

The data model#

Two tables. jobs is the definition. runs is the timetable and the history, laid out so the question a scanner asks every second, "what in my partition is due now", is one short range read.

jobsrelational, sharded by job_id
job_idbigintPK
ownervarchar(64)IDXThe team, for quotas and for "show me my jobs".
schedulevarchar(64)A cron expression, or null for a one off job.
timezonevarchar(40)An IANA zone like Asia/Kolkata. The next run is always worked out from this, never from the server's clock.
target, payloadtext, jsonWhat to call and with what.
window_sintHow late a run may start. Zero means as close to the time as possible.
misfireenumskip, run_once or run_all.
pausedboolean
Sample row
88213 | reports | 0 2 * * * | Asia/Kolkata | https://reports.internal/rebuild, {...} | 300 | run_once | false
Small, read when a run is made, and changed only when someone edits the job. Nothing here moves while runs are happening.
runsrelational, sharded by partition
partitionsmallintPKHash of job_id into, say, 1,024 slices. One scanner owns each.
due_attimestampPKIn UTC. Second in the key, so "due in partition 17 before now" is a range read.
run_idvarchar(64)PKUQjob_id plus the scheduled time. Creating the same run twice hits this key and does nothing.
stateenumscheduled, queued, running, succeeded, failed or dead.
attemptsmallintRetries so far. Past the job's limit the run goes to dead and someone is told.
lease_owner, lease_untilvarchar, timestampWhich worker holds the run and until when. A heartbeat pushes lease_until forward while it runs.
fencebigintGoes up by one every time the run is claimed. The worker sends it to the target.
Sample row
17 | 2026-10-28 20:30:00 | 88213@2026-10-28T20:30Z | running | 1 | w-42, 20:31:00 | 41
A job keeps exactly one future run in this table, written in the same transaction that finishes the current one. So the table holds what is due soon, not a calendar of every run until the end of time.

The whole system on one whiteboard#

Figure 1 is the picture at the top, laid flat, with retries and the coordination service for partition leases added.

Figure 1. The job store is the truth about every run. The queue only carries work to workers, so a message lost or delivered twice costs nothing: a run that is not claimed in the store is found again by its scanner.

Walking Figure 1:

  1. A service creates a job. The API checks the schedule and time zone, and works out the first run in UTC.
  2. The job and its first run are written in one transaction. From now on a job always has exactly one future run waiting in the table.
  3. Each scanner owns some partitions, through a lease from the coordination service (etcd or ZooKeeper). Every second or so it reads the runs in its partitions that are due and still scheduled.
  4. It marks them queued and puts them on the run queue. The queue spreads work over the workers and soaks up the 09:00 spike, so the scanner never waits on a slow target.
  5. A worker takes a run from the queue.
  6. It claims the run in the job store with one conditional update: running, a 60 second lease, fence plus one, only if nobody holds a live lease. Zero rows changed means someone else has it, and the worker drops the message.
  7. It calls the target with the run id and the fence token, heartbeats the lease while it waits, and then, in one write, records the result and creates the job's next run. A failure goes to a retry delay, and a run out of tries goes to the dead list, where someone is told.
Your answer

Step 6 gives a worker a 60 second lease. The worker freezes for 90 seconds in a garbage collection pause, in the middle of the run. What happens, and what stops it from doing harm when it wakes up?

Step 3: Design deep dive#

Finding what is due, without one box that must not die#

The whole job of the scheduler is to notice that a time has come. The simple ways to notice either do not scale or quietly depend on one machine staying alive.

How does the system know which runs are due right now?

  1. First try
    Cron on one machine
    Put every job in a crontab on one server. Cron wakes up each minute and runs what is due.
    Where it breaks: One machine. When it dies, nothing runs and nothing notices, and when it comes back nothing knows what it missed. Running two copies for safety runs everything twice.
  2. Better
    Every scheduler polls the runs table
    Store runs in a table, and have several scheduler processes each select "due and not yet taken" every second.
    Where it breaks: They all read the same rows and race to take them. With row locking that skips locked rows it works, but every process scans the hottest part of one big index every second, and at 09:00 they fight over the same few million rows.
  3. What you ship
    Partition the runs, and give each partition one owner
    Hash each job into one of 1,024 partitions. A scanner holds a lease on some partitions and reads only those, as a short range on partition and due time. If a scanner dies, its lease runs out and another scanner takes its partitions over. Nobody races, and the load spreads as you add scanners.

The third rung is leader election with leases, applied once per partition instead of once for the whole system. A scanner that loses its lease has to stop reading its partitions at once, and if it does not, nothing bad follows: a run it enqueues twice is claimed once in the store, which is the next deep dive.

Do not make the queue the timetable

Some queues can hold a message until a delay passes, so it is tempting to put every future run straight on the queue with a delay. It works for "retry in 30 seconds". It does not work for a run in six months: you cannot list, edit, pause or delete a message buried in a queue, and most queues cap how long a delay can be. Keep the timetable in a table you can query, and use the queue only for runs that are due now.

Running it once, when a worker might run it twice#

A worker can die, or freeze, at any moment. If the scheduler waits for proof before running a run again, a dead worker means a skipped run. If it does not wait, a frozen worker that wakes up means a run that happened twice.

How do you make sure a run happens, without it happening twice?

  1. First try
    Mark it running, then run it
    The worker sets state to running and starts the work. Nobody else touches a running run.
    Where it breaks: The worker dies. The run says running forever, and nothing runs it. You have traded duplicates for silent skips, which is worse.
  2. Better
    A lease with a heartbeat
    The worker claims the run for 60 seconds and renews the lease every 20 while it works. If the heartbeats stop, the lease runs out and another worker may claim the run.
    Where it breaks: A worker that freezes for longer than its lease, in a garbage collection pause or a stalled VM, wakes up still believing it owns the run. Meanwhile a second worker has claimed and finished it. Both write.
  3. What you ship
    A lease, a fence token, and a run id for the target
    Every claim bumps the run's fence number. The worker sends the fence with its writes and the run id as an idempotency key. The job store refuses a result from an older fence, and the target refuses to repeat work it has already done for that run id.

Try the race. Push the pause past the lease, then turn the fence check on.

The paused lock holder
15 s
25 s
Worker 1Worker 2Storage0 s15 s30 s45 s60 slease, token 33check: minepausedwrite (33)lease, token 34write (34)last write: worker 1 (stale)
Worker 2 got the lease at 15 s and wrote. Worker 1 wakes at 30 s, still sure the lease is its own (it checked at 4 s), and writes on top. The storage accepts both. The older worker's stale result is now the stored one, and nobody got an error.
Renewing the lease does not help: a paused process cannot renew. Neither does a shorter lease, which only makes a long enough pause rarer.

So the honest promise is at least once execution with effects that happen once, as long as the target uses the run id. The scheduler cannot do that last part for the target. It can make it easy: one header, stable across retries, documented on day one. The full argument is in exactly-once in practice.

Recurring jobs, and the clock#

A recurring job is where a scheduler earns its keep or quietly loses runs. Three questions come up in every round.

Where does the next run come from? Not from a calendar of every future run. When a run finishes, the same write that records its result creates the next run, worked out from the cron expression in the job's own time zone. A job always has exactly one future run, so editing or pausing it touches one row.

What about runs missed while the scheduler was down? That is the misfire policy, and it is per job, because the right answer depends on what the job does. Try it.

The scheduler comes back. What runs?
2 h
200k
An ordinary minute20,000 runs
First minute after recovery, on top of it200,000 runs
Each job runs one catch up run straight away: 200,000 runs in the first minute, 10 times an ordinary minute. Right for most jobs, because a sync or a cleanup only needs to see the latest state once.
Every job here runs every ten minutes, and the first minute after recovery is compared with an ordinary minute. Real fleets mix schedules, so the policy is set per job.

What about time zones? "Every day at 02:00, Asia/Kolkata" is easy, because India has no daylight saving, so every day has exactly one 02:00. "Every day at 02:30, America/New_York" is not. On the night the clocks go forward, 02:00 jumps to 03:00 and 02:30 does not exist. On the night they go back, 01:30 happens twice. Cron implementations disagree about what to do, so a shared scheduler picks a rule and writes it down: a skipped time runs at the first moment after the gap, and a repeated time runs once. Teams in India tend to meet this bug only when they start scheduling for customers abroad, which is exactly when nobody is testing for it.

The follow up you will get

"Why not store every run in UTC and forget time zones?" Store when a run is due in UTC, yes. But work out the next run from the job's own zone, every time. A job set for 09:00 London is at 08:00 UTC in summer and 09:00 UTC in winter, and a job that saved "08:00 UTC" once is an hour wrong for half the year.

Break it#

Scheduler load
08:59
08:5909:00a target slowsscanner diesrepaired
Healthy. About 580 runs start a second. Scanners read a few rows per partition per second, workers are mostly waiting on targets, and the queue is nearly empty.
Read every step as text
  1. 08:59 · Healthy. About 580 runs start a second. Scanners read a few rows per partition per second, workers are mostly waiting on targets, and the queue is nearly empty.
  2. 09:00 · Degraded. Millions of runs fall due in one minute. Scanners enqueue them in seconds, and the queue holds the backlog while workers drain it. Runs with a window start over the next few minutes instead of all at once, which is the only reason the store keeps up with the claims.
  3. a target slows · Down. One popular target starts taking 30 seconds to answer. Workers calling it sit waiting, and if workers are shared, runs for every other team wait behind it. Retries make it worse, because each failed call comes back for another go.
  4. scanner dies · Degraded. A scanner host dies holding 300 partitions. Nothing in those partitions is enqueued until its leases run out, about 10 seconds, and another scanner takes them over and reads everything due since. Runs start a few seconds late. None are skipped.
  5. repaired · Healthy. A concurrency limit per target, so one slow target can only take its share of workers, with a circuit breaker that pauses its runs instead of burning retries. Exponential backoff with jitter on retries. Windows encouraged on every round time job. None of it changed the core; it changed who waits for whom.

The slow target is the failure that actually happens. The scheduler is healthy, the target is not, and a shared worker pool turns one team's bad day into everyone's. The fixes are the ones in retries and circuit breakers: a bulkhead per target, backoff with jitter, and a breaker that stops calling a target that is down.

Trade-offs#

ChoiceWhat you gainWhat you payPick it when
The job store is the truth, the queue only carries workA lost or duplicated message costs nothing, because the run's state lives in a table you can query.Every run is written twice: a claim and a result, on top of the queue.Always for a shared scheduler. A queue alone cannot answer "did my job run".
Partitions with one scanner eachNo races between scanners, and capacity grows by adding scanners.A dead scanner delays its partitions by one lease length.From the start. One leader for everything is the same design with one partition.
At least once, with a run id and a fence tokenNo run is ever skipped because a worker died.Targets must be idempotent on the run id, and some teams will not read the docs.Always. Exactly once execution across crashes is not available, so be honest about it.
One future run per job, made when the last one finishesEditing, pausing or deleting a job touches one row.A bug in working out the next time stops that job until someone notices, so alert on jobs with no future run.Recurring jobs. One off jobs simply have a single run.

The level bar#

One moment from this round, answered three ways. Pick the one you think is staff level before the labels appear. (What changes at senior and staff explains the gap.)

Pick the staff level answer

InterviewerA team wants exactly once execution for their nightly payout job. What do you tell them?

Show which is which without guessing
  • Answer A: Mid level. A lock with an expiry is a lease, and a frozen worker outlives its lease. The promise sounds safe and is not.
  • Answer B: Staff. Refuses the impossible promise, gives a concrete way to get the effect they want, and moves the safety into the job's own design.
  • Answer C: Senior. The right promise and the right tool. Leaves the team to work out how to use it.

Then run the question cold, with a 45 minute timer and no notes, and score the run on the five things this question is really testing.

Score your last run
0 / 10
  1. The load
  2. Finding due runs
  3. Running once
  4. Recurring jobs
  5. Failure
Pick 0, 1 or 2 on every row. Be the strict interviewer, not the kind friend.

Interview replay#

Interviewer
Fifty million scheduled runs a day. Where is the hard part?
Checking whether you go to the average or the peak.
You
Not the average, that is under 600 a second. The hard parts are the round times and the failures. If 5% of runs are set for 09:00 exactly, that minute is about 42,000 starts a second, so I would size for it and ask jobs for a start window. The other hard part is the promise: a due run must start even if the machine that was going to start it dies, without running twice in a way that does harm.
Names both hard parts, with the arithmetic for the first.
Interviewer
How does the system know what is due?
The core mechanism.
You
Runs live in a table keyed by partition, then due time. I hash jobs into about a thousand partitions and each scanner holds a lease on some of them, so each one reads a short range of its own rows every second and nobody races. If a scanner dies, its leases run out and another takes over its partitions, and it picks up everything due since. Runs start a few seconds late, none are lost.
Partitioning plus leases, and says what failure costs.
Interviewer
A worker freezes for ninety seconds in the middle of a run. Its lease was sixty.
The classic trap.
You
Then another worker claims the run after sixty seconds, with a higher fence token, and probably finishes it. When the first one wakes up it still thinks it owns the run. Its result write carries the old fence, so the job store rejects it, and the target got the same run id from both workers, so it can refuse to do the work twice. The worker cannot fix this alone, because it cannot know it was frozen.
Fence token for the store, run id for the target, and why the worker cannot help.
Interviewer
The scheduler was down from 1am to 3am. A job runs every ten minutes. What happens at 3am?
Misfires.
You
It depends on the job, so it is a policy on the job. Most jobs want one catch up run, because a sync only needs the latest state. Some want none, like a status report about "now". Very few want every missed slot, and for those I would spread the twelve runs out rather than fire them at once, or a thousand such jobs become twelve thousand calls in one minute.
Makes it a per job decision and sees the spike the naive policy makes.
Interviewer
What does your cron do at 02:30 New York time on the night clocks go forward?
Checking whether you have ever been burnt by daylight saving.
You
That time does not exist that night. I am not sure what every cron implementation does there, and they differ, so I would pick a rule and document it: a skipped time runs at the first moment after the gap, and a repeated time runs once. And I would always work out the next run from the job's own time zone, never from a stored UTC time.
Admits not knowing every implementation, then makes the behaviour explicit.

Checkpoint#

Checkpoint

1. 50 million runs a day. About how many start per second on average?

2. Worker 1's lease ran out while it was frozen. Worker 2 claimed the run and finished it. Worker 1 wakes up and writes its result. What stops it?

3. Same scheduler, but the jobs are six hour training runs instead of thirty second calls. What changes first?

Worth memorising
  • 50 million runs a day is about 580 a second. The busiest minute can be 70 times that, so design for the minute, not the day.
  • A run's id is its job plus its scheduled time. Creating the same run twice does nothing.
  • Leases expire, so a run can execute twice. The fence token protects the store and the run id protects the target.
  • Missed runs are a policy per job: skip, run once, or run every missed slot.
Say this in 60 seconds

A shared scheduler stores jobs as definitions and runs as rows: each run's id is the job id plus its scheduled time, and a job always has exactly one future run, created in the same write that records the last result. Runs are hashed into about a thousand partitions, and each scanner holds a lease on some of them, so it reads a short range of due runs every second with no races, and if it dies another scanner takes over within one lease. Due runs go onto a queue that spreads work and absorbs the round time spikes, which can be seventy times the average, and start windows flatten them further. Workers claim a run in the store with a short lease, a heartbeat and a fence token, and call the target with the run id as an idempotency key. That makes the honest promise at least once execution with effects that happen once, because a frozen worker's late write carries an old fence and the target refuses a repeated run id. Missed runs follow a misfire policy set per job, and the next run is always worked out from the job's own time zone. The failure I would call out is one slow target tying up every worker, so I would give each target a concurrency limit and a circuit breaker.