System design interview

Design a distributed job scheduler

A due-job index, leases, retries, cron recurrence and midnight spikes.

At a glance

Level
Intermediate
Topics
Queues and streams, Consistency
Main parts
Scheduler API · Job store · Poller · Worker · Run queue · Target service
What you'll learn
Practise this problem
Overview
ClientSchedulerAPIJob storePollerWorkerRun queueTargetserviceClientScheduler APIJob storePollerclaims due jobsWorkerruns, heartbeatsRun queueTarget service

Press Play to follow a request through the design, or step through it.

  • Client and edge
  • Service
  • Data store
  • Cache and queue
  1. A team registers a job: a cron expression for 08:00 every Monday, a time zone, a target and a payload.
  2. The API computes the first due time and stores the job with that time, in the partition chosen by its id.
  3. The store confirms the write after it is replicated.
  4. The team gets the job id and sees the next run time.
  • Client
  • Scheduler API
  • Job store: jobs, leases, runs
  • Poller: claims due jobs
  • Worker: runs, heartbeats
  • Run queue
  • Target service: does the real work
On this page

In 2 minutes

Key ideas
  • The numbers. Average load is tiny, and the spike is 36 times larger.
  • Finding due jobs. A time-bucketed index, polled each second.
  • Delivery guarantee. At least once, with an idempotency key.
  • Worker liveness. A short lease that starts at pickup, with heartbeats.
  • Creating the next cron run. When the current run is claimed.
Read the 2-minute answer

I would keep every job in a replicated job store, indexed by partition and due time, with 64 partitions so the work can be spread. Pollers each own some partitions. Every second, a poller claims the due jobs with a conditional update that creates a queued run row (job id plus due time, with a queue deadline) and moves the job's next due time forward in the same update, then puts a run message on a queue. A worker that takes a message starts a 60-second lease on the run, so queue wait never counts against it, calls the target with the run id as an idempotency key, and extends the lease with heartbeats.

If a worker dies, the lease on its run row runs out and another worker repeats the run. The target uses the key to return the saved result, so a repeat does no harm. That gives at-least-once with an effect close to once. For cron jobs I compute the next run from the last scheduled time, and write it in the same update that claims the run, so the schedule survives failed runs.

The spike is the hard part: 500,000 jobs due at midnight is 8,300 starts a second, 36 times the average. I would offer jitter, preload the next minute into memory and scale workers ahead of time. The main trade-off is that runs can repeat, in exchange for never losing a job.

Step 1 of 6: Clarify the scope

Questions to ask first

Ask these before you draw anything. Guess why each answer changes the design, then open it to check.

What does a job do: call a URL, or run code, and how long can it take?
Why it matters: Run time sets the lease length and decides whether workers must keep extending their claim on the job.
Are jobs one-off, recurring on a cron schedule, or both?
Why it matters: Recurrence means each run creates the next one, and that hand-off must not be lost or doubled.
How precise must start times be?
Why it matters: A few seconds of lateness allows polling once a second. Millisecond precision needs in-memory timers.
Is exactly-once required, or is at-least-once with safe retries acceptable?
Why it matters: No scheduler can guarantee a remote call happened exactly once, so the usual answer is at-least-once plus idempotent jobs.
How many jobs are there, and what happens at the top of the hour or at midnight?
Why it matters: A pile-up of jobs due at the same moment, not the average load, is what the design has to survive.

Requirements

Functional

  • Create, update, pause and cancel a job: a one-off time or a cron expression, a payload and a target.
  • Run each job at its due time, and record the outcome of every run.
  • Retry a failed run with backoff up to a limit, then mark it dead and alert the owner.
  • Show a job's next run time and the history of its runs.

Non-functional

  • 99% of runs start within 5 seconds of their due time, and within 60 seconds in a midnight spike.
  • No accepted job is lost when any single node fails.
  • Every due job runs at least once, and a run can be repeated safely with the same idempotency key.
  • Handles 10 million jobs and a spike of 8,300 run starts a second.

Step 2 of 6: Size it

Show the maths

These numbers are assumptions for the exercise, not facts about a real system:

  • 10 million active jobs, and together they run 20 million times a day.
  • 5% of jobs, or 500,000, are set for exactly midnight, and their runs may start any time within a minute.
  • A job record takes about 100 bytes, and a run record about 200 bytes. Run history is kept for 30 days.
  • The due-job index is split into 64 partitions, and a worker starts about 100 runs a second.

The arithmetic:

Average run starts per second
20M ÷ 86,400 s ≈ 231
≈ 230/s
Midnight spike
500,000 runs ÷ 60 s ≈ 8,333/s; ÷ 231 average
≈ 8,300/s, 36 times the average
Load per partition
8,333/s ÷ 64 partitions
≈ 130 claims a second per partition at the peak
Workers for the spike
8,333/s ÷ 100 starts a second per worker
≈ 84 workers, so about 90 with headroom
Job table
10M × 100 B
1 GB, small enough for one replicated database
Run history
20M × 200 B = 4 GB a day; × 30 days
≈ 120 GB

Step 3 of 6: Sketch the path of one request

Step through the diagram at the top of the page to watch a request move through these parts.

Teams call the Scheduler API to register a job. It writes the job to the Job store with its next run time. The store is the single source of truth, and it keeps jobs indexed by when they are due.

A fleet of Pollers each owns some of the 64 partitions. Every second, a poller claims the due jobs in its partitions with one conditional update. The update creates a run row (run id = job id plus due time, in the queued state with a queue deadline) and, for a cron job, moves the job's next due time forward. It then puts one message per claimed run on the Run queue.

Workers take messages from the queue. A worker first marks the run row as running, only if it is still queued, and sets a 60-second lease. The lease starts here, so time spent waiting in the queue never uses it up. The worker then calls the Target service, passing the run id as an idempotency key, and keeps extending the lease while the run is going. When it ends, the worker records the result on the run. If a worker dies, the lease runs out and a poller re-claims that run and re-queues it. A run still queued past its deadline, because a message was lost, is re-queued the same way. The target sees the same run id and does not repeat the work.

Other paths through the system

Run a due job

  1. Each second, a poller claims due jobs in its partitions. One update creates a queued run row with a queue deadline, and moves the job's next due time forward.
  2. The store returns the claimed runs, at most 500 at a time.
  3. The poller puts one run message on the queue for each claimed run, with a run id of job id plus due time.
  4. A worker takes a message and marks the run running, only if still queued. This starts a 60-second lease, which a heartbeat then extends.
  5. The worker calls the target service and sends the run id as an idempotency key.
  6. The target does the work and answers success.
  7. The worker records the success on the run row. The next run time was already set when the run was claimed.

Recover from a crash

  1. A worker calls the target, and the target does the work. Then the worker crashes before saving the result.
  2. The worker's heartbeats to the job store stop, so the lease is never extended again.
  3. The run row's lease has now expired, so a poller re-claims that run.
  4. The poller puts the run back on the queue, with the same run id as before.
  5. Another worker takes the message and starts the run.
  6. It calls the target again with the same idempotency key.
  7. The target sees that it already did this run and returns the saved result without repeating the work.

Step 4 of 6: Go deep on the hard part

Finding due jobs fast

InterviewerWith 10 million jobs, how do pollers find what is due now without scanning everything?Scanning 10 million jobs every second is out of the question.

Scanning 10 million jobs every second is out of the question. Instead, make "what is due now" a cheap range read by choosing the right index.

Index jobs by the key (partition, due time, job id). The partition is the job id hashed into one of 64 buckets, so it never changes. Within a partition, jobs sort by due time, so the due ones are the first rows. A poll is then "in my partitions, give me rows with due time at or before now, oldest first, limit 500". Most polls read nothing, which costs almost nothing. To keep even old rows out of the way, group the index in time buckets of one minute. A poller reads the current bucket and any older bucket that still has rows, which are overdue jobs.

Who polls which partition. Give each poller a set of partitions through membership in a small coordination store: with N live pollers, poller i owns the partitions p where p mod N = i, and the set moves when pollers join or leave. During a move, two pollers may briefly poll the same partition. That is why the claim itself is a conditional update. Only one update wins per job, because the claim succeeds only while the job's due time has not yet been moved forward.

Latency. Polling once a second adds up to a second of delay. Add the queue and the worker's start, and the whole path stays inside the 5-second target.

Precision on demand. For tighter timing, a poller can load the next minute's jobs for its partitions into memory shortly before the minute starts. It keeps them in a timer structure, such as a min-heap ordered by due time, and fires each at its exact time. A restart reloads the minute from the store, because nothing was claimed yet. The same preload also protects the midnight spike. The 500,000 jobs due at midnight are about 50 MB of rows, small enough to hold in memory, so the poller can queue them in the first moments after midnight without a burst of database reads.

Leases, retries and idempotency

InterviewerHow do you run each job at least once, survive a worker crash, and avoid doing the work twice?Leases. A claim is not forever, and the lease must start when work starts.

Leases. A claim is not forever, and the lease must start when work starts. A poller's claim only creates the run row as queued, with a queue deadline such as 10 minutes. A worker that takes the message makes one conditional update: if the run is still queued, set its owner, the attempt number and a lease expiry 60 seconds ahead. Then it extends the lease every 20 seconds while it is alive. A worker that loses this update drops the message, which also absorbs duplicate messages. If a worker dies, the extensions stop and the lease passes. Pollers scan the run rows, indexed by expiry, and re-queue any running run whose lease has expired, or any queued run past its deadline, which means a message was lost. Compare lease times using the job store's own clock, not the workers' clocks, so a skewed machine cannot steal or hold a lease.

Why at-least-once, not exactly-once. A worker calls the target and then crashes before it records the result. From the scheduler's side, the target may or may not have acted, and there is no way to tell. It can either risk skipping the run or risk repeating it. A scheduler should choose repeating, because a missed job is usually worse than a duplicate.

Making a repeat harmless. Every run has a run id: the job id plus the due time. The worker sends it to the target as an idempotency key. The target stores the key with the result, for a day or longer, and on a repeat returns the saved result. Targets that cannot do that must make the work itself safe to repeat, for example by writing "set the report to sent" rather than "add one to the sent count". The scheduler's contract should say so plainly.

Stale workers. A worker may pause for longer than its lease and then wake up and try to record its result, after another worker has taken the run. Make the result write conditional on the lease owner and attempt number, so the late write is rejected.

Retries. On failure or timeout, schedule another attempt with exponential backoff and a little random jitter, such as 10 seconds, 30 seconds, then 2 minutes. Retry timeouts and server errors, and do not retry errors that cannot succeed, such as a bad request. After a limit of attempts, mark the run dead, keep its payload and alert the owner. Also cap how many workers one team may use at once, so a team with a flood of slow jobs cannot starve everyone else.

Cron, misfires and the midnight pile-up

InterviewerHow do you handle repeating schedules, an outage that makes jobs late, and thousands of jobs due at midnight?Recurrence. Store the cron expression and a time zone name.

Recurrence. Store the cron expression and a time zone name. Compute the next due time from the last scheduled time, not from now, so a late run does not push the next one back and the schedule does not drift. The claim creates the run row and writes the next due time in the same update. If the next run were only created after the current one finished, a crash or a failed run would end the schedule silently. With this order, the schedule continues even when a run fails. The lease lives on the run row, apart from the job's due time, so recovering a crashed run never disturbs the schedule.

Time zones. Use zone names, not fixed offsets, and let a time zone library handle daylight saving time. On the day clocks go forward, a 02:30 job has no 02:30. Run it at the next valid time. On the day clocks go back, 01:30 happens twice. Run it once. Say these rules in the documentation.

Misfires. If the scheduler is down for two hours, an hourly job missed two runs. The right answer depends on the job, so make it a setting: run once now and move on, run every missed run, or skip to the next. Running once is a good default, since a report sent twice in a minute is no use. A billing job might need every run. Overdue rows are easy to find, because they sit in older time buckets.

The midnight pile-up. Many teams pick midnight sharp. Our assumption has 500,000 jobs due at once, which is 8,300 starts a second if they fire within a minute. There are three defences. Offer jitter: spread each job over a window, for example by the job id modulo five minutes, and make that the default for jobs that do not need precision. Over five minutes, the same 500,000 jobs need only 1,670 starts a second. Keep an exact-time option, with a quota, for jobs that truly need it. Preload the minute into memory as described earlier. And scale workers up ahead of the busy times, using a schedule or the queue depth.

Step 5 of 6: Name the trade-offs

Finding due jobs
Chose
A time-bucketed index, polled each second
Over
A timer in memory for every job

State lives in the database, so a restart loses nothing, and a poll is a cheap range read. The cost is up to a second of delay, which the preload trick can remove.

Delivery guarantee
Chose
At least once, with an idempotency key
Over
At most once

A crash never silently skips a job. The cost is that a run may repeat, so targets must be safe to repeat.

Worker liveness
Chose
A short lease that starts at pickup, with heartbeats
Over
One long timeout per job

A dead worker is noticed within a minute, not after the longest possible run. The cost is heartbeat traffic and a rule for stale workers.

Creating the next cron run
Chose
When the current run is claimed
Over
After the current run finishes

The schedule survives failed or crashed runs. The cost is that two runs of a slow job can overlap unless the job asks to skip an overlap.

Step 6 of 6: Say what breaks next

Follow-up questions an interviewer asks once the design is on the board. Answer each one out loud, then open it to compare.

How would you add dependencies, such as run B after A succeeds?
Store the dependency as data and keep a count of unfinished parents on the child. When a parent succeeds, the worker lowers the count in the same update that records the result. A child with a count of zero becomes due. This is a small workflow engine built on the same store.
How do you stop one team from starving the others?
Put a limit on the number of runs per team that may be in flight, and use separate queues or weights per team or priority class. A team that exceeds its limit waits in its own queue while others keep moving.
What if the job store is the bottleneck?
Partition the store itself by the same partition id, with each partition on its own shard and replica set. Pollers and workers then talk only to the shard that owns their partitions. The 64 partitions can be spread over many machines as load grows.
How do you cancel a run that is in progress?
Mark the run as cancel requested in the job store. The worker sees the flag on its next heartbeat, which returns the flag, and tells the target to stop if the target supports that. Otherwise the run finishes and the result is recorded as cancelled late.

Free ATS check

See your resume the way an ATS reads it, then fix what it misses.

Drop in a PDF for a score out of 100, every fix that cost a point, and the line it belongs to. It runs in your browser, with no signup for the score.