System design interview: design a distributed job scheduler
A distributed job scheduler stores every job durably with its next run time, has a small set of scheduler nodes (each owning a slice of the jobs) move due jobs onto a queue, and lets workers take them under a time-limited lease. Because a lease can expire while a slow worker is still running, the honest guarantee is at-least-once execution, so the design depends on idempotent jobs, retries with backoff and a dead-letter queue.
By Evin JohnPublished 9 min read
1. Clarify the requirements
"Design a job scheduler" covers anything from a cron replacement to a workflow engine. Agree on the job types and the guarantee before anything else.
- Functional: schedule a one-off job to run at a given time ("send this reminder at 09:00 tomorrow"); schedule recurring jobs with a cron expression and a time zone; cancel, pause and resume jobs; retry failed runs; show the status and history of each job.
- Non-functional: an accepted job is never lost; a due job starts within a few seconds of its time; the system keeps running when any single node fails; one noisy tenant cannot starve the others.
- Out of scope unless asked: dependencies between jobs (a DAG or workflow engine), the business logic inside each job, sub-second precision.
2. Estimate the scale
These are assumed round numbers for a shared internal platform, chosen to make the arithmetic easy.
| Quantity | Assumption | Result |
|---|---|---|
| Pending jobs | 500 million scheduled, mostly one-off reminders | 500 million x 1 KB = about 500 GB of job records |
| Runs | 200 million executions a day | about 2,300 a second on average |
| Peak | cron jobs bunch at the top of the hour: assume 10x | about 23,000 runs a second for short bursts |
| Run history | 500 bytes per run, kept 30 days | 100 GB a day, about 3 TB retained |
| Worker slots | 2 seconds per run on average (Little's law: rate x duration) | about 4,600 concurrent runs on average, 46,000 at peak |
| Workers | 50 concurrent runs per worker | about 920 workers at peak, or fewer if peaks are smoothed |
Two conclusions: storage is small, so the hard part is coordination, not volume; and the top-of-the-hour spike, not the average, sets the worker count. Smoothing that spike is worth a sentence later.
3. Define the API
POST /api/v1/jobs
body: { "type": "send-invoice", "payload": { "invoiceId": 8812 },
"runAt": "2026-11-02T09:00:00Z", "idempotencyKey": "invoice-8812" }
or: { "type": "daily-digest", "cron": "0 9 * * MON-FRI",
"timeZone": "Europe/London", "missedRuns": "run-once", "maxAttempts": 8 }
returns: 201 { "jobId": "j_7hq2", "nextRunAt": "2026-11-02T09:00:00Z" }
GET /api/v1/jobs/{jobId} status, next run time, recent runs
DELETE /api/v1/jobs/{jobId} cancel
POST /api/v1/jobs/{jobId}/pause and /resume
Internal, worker to scheduler:
POST /internal/leases { "workerId": "w-311", "types": ["send-invoice"], "max": 10 }
returns: [{ "runId": "r_90", "runKey": "j_7hq2@2026-11-02T09:00Z", "attempt": 1,
"fence": 5521, "leaseExpiresAt": "...", "payload": { ... } }]
POST /internal/leases/{runId}/heartbeat extends the lease
POST /internal/leases/{runId}/complete { "fence": 5521, "result": "succeeded" | "failed", "retryable": true }4. Sketch the data model
| Table | Key fields | Why it is there |
|---|---|---|
jobs | job_id (partition key), tenant_id, type, payload, run_at or cron + time_zone, next_run_at, status, missed_runs, max_attempts | The definition; recurring jobs keep one row and update next_run_at |
runs | run_id, job_id, scheduled_for, attempt, state, lease_owner, lease_expires_at, fence, next_attempt_at, error | One row per firing; unique on (job_id, scheduled_for) so a firing can only be created once |
| Due index | (partition, next_run_at) to job_id | Lets a scheduler find what is due soon in its own partitions |
dead_letters | run_id, last error, attempts, time | Runs that exhausted their retries, kept for inspection and manual replay |
| Partition ownership | partition, owner, epoch, lease_expires_at (in a coordination service) | Which scheduler node is responsible for which partition right now |
The unique constraint on (job_id, scheduled_for) does a lot of quiet work: whatever goes wrong upstream, two schedulers cannot both create the 09:00 run of the same job.
5. Find due jobs on time
The central question is how the system notices that a job is due without scanning 500 million rows. There are three common answers.
| Approach | How it works | Strength | Cost |
|---|---|---|---|
| Database polling | Every second, SELECT ... WHERE next_run_at <= now() ... LIMIT 500 FOR UPDATE SKIP LOCKED on an index | Simple, transactional, easy to reason about | Constant polling load; the index on time becomes a hot spot as volume grows |
| Time buckets | Jobs are written under a key of (minute, shard); a scheduler reads the current minute's buckets | Scales horizontally in a wide-column store; reads are cheap and predictable | Precision is one bucket; jobs added late to the current bucket need a second look |
| In-memory timer | Each scheduler loads the next few minutes of its jobs into a min-heap or a hierarchical timing wheel and fires them on time | Precise to the second; each tick costs almost nothing | Memory-bound; must reload from the store after a restart or ownership change |
A strong default combines them. The store is partitioned by a hash of job_id into, say, 1,024 partitions. Each scheduler node owns some partitions and, every 30 seconds, loads jobs due in the next two minutes into a timing wheel. A timing wheel is a ring of slots, one per second, where inserting a timer and advancing the clock are both constant time. When a slot fires, the scheduler creates the run row and pushes it onto a ready queue, then computes the job's next next_run_at. A job created for the next few seconds goes into the wheel directly as well as into the store.
Partitioning by job id rather than by time matters: partitioning by time would send all current work to one partition. See sharding strategies for the general rule.
6. The architecture
- Clients
- Services and admin UI
- API tier
- Job API (validation, cron parsing, rate limits)
- State
- Job store, partitioned by job id
- Runs table (unique job + time)
- Coordination service (partition leases)
- Scheduling
- Scheduler nodes, one owner per partition
- Timing wheel per node
- Dispatch
- Ready queue per job type and tenant
- Execution
- Worker pools
- Heartbeats
- Lease reaper
- Async
- Retry planner (backoff)
- Dead-letter queue
- Run history and alerts
- ScheduleThe API validates the job, computes the first
next_run_atin UTC and writes the row. Nothing else happens until the job is nearly due. - FireThe owning scheduler's timer fires. It inserts the run row (a duplicate insert fails harmlessly on the unique key), enqueues the run and advances the job's
next_run_at. - LeaseA worker takes the run with a lease of, say, 30 seconds and a fencing number, and heartbeats every 10 seconds while it works.
- FinishThe worker reports success or failure with its fencing number. Failures are retried later or, after the last attempt, sent to the dead-letter queue.
7. Execute reliably
- Leases and visibility timeouts. A leased run is invisible to other workers until the lease expires. If the worker crashes, heartbeats stop, the lease expires and the run becomes available again. This is the same idea as a message queue's visibility timeout.
- Why at-least-once. A worker can be alive but paused (a long garbage-collection pause, a network partition) past its lease. Another worker then takes the run, and both may finish it. No scheduler can rule this out for jobs with outside side effects, so the run will sometimes execute twice.
- Idempotent handlers. Pass a stable run key (
job_idplusscheduled_for) to the job, and have it record "run key done" in the same transaction as its effect, or pass the key as an idempotency key to the downstream API. Duplicates then do no harm: the effect happens once even if the code runs twice. - Fencing. Each lease carries an increasing fencing number. The scheduler rejects a completion with an old number, and a downstream system that checks the number can refuse writes from a worker whose lease has been taken over.
- Retries with backoff. Delay = min(cap, base x 2^attempt), plus random jitter so a failed dependency is not hit by every retry at once. With a 10 second base and a 1 hour cap, the waits run 10 s, 20 s, 40 s and so on. Do not retry errors marked permanent.
- Dead-letter queue. After
maxAttemptsthe run goes to a dead-letter queue with its last error, and an alert fires. Operators fix the cause and replay from there. Without this, a poison job retries forever. - Fairness. Separate ready queues per tenant or job type, with per-tenant rate limits, stop one tenant's backlog from delaying everyone else.
8. What breaks, and what you do about it
| Failure | Effect | Mitigation |
|---|---|---|
| Worker dies mid-run | Run stalls until its lease expires, then runs again | Short leases with heartbeats; idempotent handlers absorb the repeat |
| Worker paused past its lease | Two copies of the run execute at once | Fencing numbers on completion and downstream writes; heartbeat interval well under the lease |
| Scheduler node dies | Its partitions stop firing until their leases expire | Another node takes the partitions, reloads the next window from the store and applies the missed-run policy |
| Two nodes believe they own a partition | The same job fires twice | Ownership epochs from the coordination service; the unique (job, time) key rejects the second run row |
| Top-of-the-hour burst | Queue backlog, jobs start late | Queue absorbs the burst, workers autoscale, and jobs that tolerate it get a few seconds of random jitter |
| Clock skew between nodes | Jobs fire early or late | NTP everywhere; compare due times against one clock, the owning scheduler's |
9. Trade-offs to discuss
- Missed-run policy. If the platform is down for 20 minutes, a job that runs every 5 minutes misses 4 runs. Offer three choices per job: run every missed firing (catch-up, useful for billing periods), run once now (the usual default), or skip to the next scheduled time. Add a maximum lateness: a 09:00 digest delivered at 15:00 may be worse than none.
- Time zones and DST. Store
0 9 * * *withEurope/London, not as a UTC time, and compute each next run with a time zone library at the moment you schedule it, because time zone rules change. In the UK, clocks jump from 01:00 to 02:00 on the last Sunday of March, so local times between 01:00 and 01:59 do not exist that day; on the last Sunday of October they happen twice. Decide the rule: run a skipped time at the next valid instant, and run a repeated time once. Interval jobs ("every 15 minutes") should be computed in UTC so they are unaffected. - Lease length. Short leases recover from crashes quickly but cause more duplicates and more heartbeat traffic; long leases do the opposite.
- At-least-once or at-most-once. Marking a run done before executing it gives at-most-once: no duplicates, but a crash loses the run. That fits work where a miss is cheap and a repeat is not, and nowhere else.
- Precision or cost. One-second precision needs in-memory timers; one-minute precision is fine with time buckets and polling alone.
10. How to talk through it in the interview
Fictional example
- Interviewer
- Can you guarantee a payment reminder email goes out exactly once?
- Candidate
- Not execution exactly once. A worker can stall past its lease and another worker can start the same run. What I can guarantee is that the run executes at least once, and that the effect happens once: the run key goes to the email service as an idempotency key, and it drops the second request.
- Interviewer
- And if the email service does not support idempotency keys?
- Candidate
- Then there is a window I cannot close. If the job records the run key after sending, a crash in between sends a duplicate; if it records first, a crash loses the email. For a reminder I would record after sending and accept a rare duplicate, and I would say that choice out loud rather than hide it.
- Separate the job definition from each run early; most follow-ups are about runs.
- Name the guarantee, then show the three things that support it: leases, idempotency keys and fencing.
- Bring up DST and missed runs yourself. Few candidates do, and both are real production problems.
Practicing this design with Natively
Run the walk-through as a mock interview: ask a friend to play the interviewer on a call, or read the prompt aloud yourself. Natively transcribes the conversation as it happens and can suggest talking points for each question as it comes in; comparing them with what you said is a quick way to spot gaps such as fencing or the missed-run policy. If you draw the architecture, you can capture a screenshot of the diagram and have Natively explain it. After the session, the transcript and notes show where you ran long and what you skipped.
Questions people ask
Can a distributed job scheduler guarantee exactly-once execution?
Not for jobs with outside side effects. A worker can stall past its lease while another worker picks up the same run, so the realistic guarantee is at-least-once. You get exactly-once effects by making handlers idempotent, for example by passing a stable run key as an idempotency key downstream.
What is a lease or visibility timeout in a job scheduler?
When a worker takes a run, the run is hidden from other workers for a fixed time, and the worker sends heartbeats to extend it. If the worker crashes, the heartbeats stop, the lease expires and the run becomes available again. Short leases recover faster but cause more duplicate runs.
How should a scheduler handle cron jobs across daylight saving changes?
Store the cron expression with an IANA time zone and compute each next run in UTC at scheduling time. Decide a rule for local times that do not exist when clocks go forward (usually run at the next valid instant) and for times that occur twice when clocks go back (usually run once).
Where to go next
Practice with the same assistant you would use on a call: it listens, transcribes and suggests answers from your own resume and the job description.
Facts on this page were last checked on . Product details come from the Natively app itself; if something here is out of date, tell us at hello@heynatively.com.
