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.

Case study32 minLesson 7 of 8
On this page
  1. Clarify the problem
  2. Estimate the scale
  3. Define the API
  4. Model the data
  5. High-level design
  6. Deep dives
  7. Bottlenecks and failures
  8. What a strong answer sounds like
  9. Follow-up questions

"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

The first five minutes
Interviewer

Design a distributed job scheduler.

You

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?

Interviewer

Both kinds. Teams run their own workers; you store, schedule and dispatch.

You

How many jobs, and how precise must the timing be?

Interviewer

About 100 million job runs a day and a million recurring schedules. Starting within a few seconds of the due time is fine.

You

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.
Estimate: How long the :00 burst takes to drain

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?

You know
  • 200,000 jobs due at the same second
  • The worker fleet runs 1,800 jobs a second at full speed

Show the working
  1. 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.

Your turn: Define the API

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

Scheduler 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 lost

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

Your turn: Model the data

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, the due index and schedules
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_at

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

Your turn: High-level design

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

The job store is the source of truth. Dispatchers move due job ids onto a ready queue; workers claim each job in the store before running it.Team services to Scheduler API (POST /jobs); Scheduler API to Job store (sharded) (insert); Dispatchers to Coordinator (shard leases); Dispatchers to Job store (sharded) (scan due bucket); Dispatchers to Ready queues (job ids); Ready queues to Team workers (pull); Team workers to Job store (sharded) (claim, done).Team servicesScheduler APICoordinatorDispatchersJob store(sharded)Ready queuesTeam workersPOST /jobsinsertshard leasesscan due bucketjob idspullclaim, done
The job store is the source of truth. Dispatchers move due job ids onto a ready queue; workers claim each job in the store before running it.

Step through one delayed job, including a worker that stalls halfway.

A delayed job, a stalled worker, a second attemptUse the arrows or ← → keys
  1. A delayed job, a stalled worker, a second attempt, step 1Team services to Scheduler API; Scheduler API to Job store (sharded); Dispatchers to Coordinator; Dispatchers to Job store (sharded); Dispatchers to Ready queues; Ready queues to Team workers; Team workers to Job store (sharded).Team servicesScheduler APICoordinatorDispatchersJob store(sharded)(17, 10:05)Ready queuesTeam workers
    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.

  2. A delayed job, a stalled worker, a second attempt, step 2Team services to Scheduler API; Scheduler API to Job store (sharded); Dispatchers to Coordinator; Dispatchers to Job store (sharded); Dispatchers to Ready queues; Ready queues to Team workers; Team workers to Job store (sharded).Team servicesScheduler APICoordinatorDispatchersD2: 16-31Job store(sharded)Ready queuesTeam workers
    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.

  3. A delayed job, a stalled worker, a second attempt, step 3Team services to Scheduler API; Scheduler API to Job store (sharded); Dispatchers to Coordinator; Dispatchers to Job store (sharded); Dispatchers to Ready queues; Ready queues to Team workers; Team workers to Job store (sharded).Team servicesScheduler APICoordinatorDispatchersJob store(sharded)Ready queuesj_a8f3c1Team workers
    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 scheduled to enqueued with a conditional update, then puts its id on the emails queue.

  4. A delayed job, a stalled worker, a second attempt, step 4Team services to Scheduler API; Scheduler API to Job store (sharded); Dispatchers to Coordinator; Dispatchers to Job store (sharded); Dispatchers to Ready queues; Ready queues to Team workers; Team workers to Job store (sharded).Team servicesScheduler APICoordinatorDispatchersJob store(sharded)Ready queuesTeam workersW3 attempt 1
    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.

  5. A delayed job, a stalled worker, a second attempt, step 5Team services to Scheduler API; Scheduler API to Job store (sharded); Dispatchers to Coordinator; Dispatchers to Job store (sharded); Dispatchers to Ready queues; Ready queues to Team workers; Team workers to Job store (sharded).Team servicesScheduler APICoordinatorDispatchersJob store(sharded)Ready queuesTeam workersW3 paused
    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.

  6. A delayed job, a stalled worker, a second attempt, step 6Team services to Scheduler API; Scheduler API to Job store (sharded); Dispatchers to Coordinator; Dispatchers to Job store (sharded); Dispatchers to Ready queues; Ready queues to Team workers; Team workers to Job store (sharded).Team servicesScheduler APICoordinatorDispatchersJob store(sharded)lease expiredReady queuesTeam workersdown
    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 enqueued and puts the id on the queue again. From the scheduler's side, W3 might be dead.

  7. A delayed job, a stalled worker, a second attempt, step 7Team services to Scheduler API; Scheduler API to Job store (sharded); Dispatchers to Coordinator; Dispatchers to Job store (sharded); Dispatchers to Ready queues; Ready queues to Team workers; Team workers to Job store (sharded).Team servicesScheduler APICoordinatorDispatchersJob store(sharded)Ready queuesTeam workersW5 attempt 2
    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.

  8. A delayed job, a stalled worker, a second attempt, step 8Team services to Scheduler API; Scheduler API to Job store (sharded); Dispatchers to Coordinator; Dispatchers to Job store (sharded); Dispatchers to Ready queues; Ready queues to Team workers; Team workers to Job store (sharded).Team servicesScheduler APICoordinatorDispatchersJob store(sharded)409 attempt 1Ready queuesTeam workers
    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.

  9. A delayed job, a stalled worker, a second attempt, step 9Team services to Scheduler API; Scheduler API to Job store (sharded); Dispatchers to Coordinator; Dispatchers to Job store (sharded); Dispatchers to Ready queues; Ready queues to Team workers; Team workers to Job store (sharded).Team servicesScheduler APICoordinatorDispatchersJob store(sharded)Ready queuesTeam workers
    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-d15 so the email service drops the repeat.

1 / 9

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.

Ways to find jobs that are due
OptionGood atBad atPick it when
One table, index on run_atSimple. 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 defaultEach 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 wheelJobs 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 deliveryNo 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 diesWhat happensWhat you do
A workerIts leases run out after up to 30 seconds.The sweep re-enqueues the jobs; another worker runs them as the next attempt.
A dispatcherIts 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 coordinatorNo 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 shardJobs 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 queueEnqueued 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

LevelWhat the answer does
JuniorStores jobs in a database and has workers poll for due ones; with prompting, handles a worker that crashes.
MidUses leases and heartbeats, explains at-least-once, partitions jobs by time and handles retries with backoff.
SeniorSizes 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.
Running a job exactly onceWeak answer
Interviewer

Can you guarantee each job runs exactly once?

You

Yes. I'd take a distributed lock on the job id before running it, so only one worker can have it.

Running a job exactly onceStrong answer
Interviewer

Can you guarantee each job runs exactly once?

You

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.

Interviewer

So who is responsible for duplicates?

You

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.

Self-review: job scheduler0 of 13

After your attempt, tick what you really did. Be strict: "I thought about it" is not "I said it out loud".

Before drawing

Numbers and interfaces

Design and depth

Failure