Design a distributed job scheduler: the system design interview walkthrough
The request to design a distributed job scheduler sounds like one line — submit a job, run it at the right time — and that one line hides the whole interview. Do it on one box and you reach for cron; do it for millions of scheduled jobs firing at the right second, running reliably when a worker crashes, and never running twice, and it becomes a durable-scheduling-plus-reliable-execution problem with a dozen sharp edges. This is one of the most common job scheduler system design prompts because it forces every distributed-systems muscle at once: durable timers, partitioning, queues, delivery guarantees, and failure recovery.
The round turns on one structural insight. A single-box cron decides when and runs the work in the same process; a distributed scheduling system instead separates the two — a durable job store and a scheduler decide when and push due jobs onto a queue, while a separate pool of executors runs the work. Get that one reframe — the scheduler never runs the job, it only moves a durable job from scheduled to enqueued — and the rest of the system design interview is deciding how to find due jobs on time, how workers run them reliably without running them twice, and how to stop several scheduler nodes from firing the same job more than once. This walkthrough follows the path a strong candidate draws on the whiteboard — requirements, estimate, API and data model, high-level design, then the deep dives — and at each step it names the follow-up the interviewer uses to tell a memorised answer from an engineer who understands the tradeoff.
How the job scheduler round is actually run
A job scheduler round runs about 60 minutes on a shared canvas — you draw the boxes and arrows while you talk, and the interviewer interrupts. It is a favourite because the one-line problem opens onto deep water fast: the moment you draw "scheduler fires jobs, workers run them," the question becomes what happens when a worker dies mid-job, and every answer after that (retries, idempotency, double-firing) compounds from how cleanly you separated deciding-when from running-the-work. Get that split clean and you have shown the whole loop in one problem.
The interviewer rarely lets "the scheduler fires jobs and workers run them" stand. "A worker leases a job, starts running it, and crashes halfway through — does that job run twice, or never? Walk me through exactly what has to be true for the answer to be 'exactly the right number of times.'" That question — reliable execution under failure — is the one a copied answer can never rehearse, and the one this round is really testing.
The rounds here are modelled on how the loop runs, not scraped from any candidate's recall. What matters for prep is the shape: a blank canvas, a clock, and a follow-up after every decision you make.
The five-step framework for any HLD round
Every high-level design answer fits the same spine. Say it out loud at the start so the interviewer knows you have a plan, then walk it:
- Clarify requirements — functional and non-functional, and what you are explicitly not building.
- Estimate — number of scheduled jobs, jobs due per second at peak, execution time, and retry amplification, to justify every later choice.
- High-level design — the boxes and arrows: submit API, durable job store, scheduler, task queue, workers.
- Deep dive — the two or three decisions that actually decide this system.
- Follow-ups — bottlenecks, failure modes, and what you would change at ten times the scale.
What the debrief measures
The score is not "right or wrong design." A good HLD debrief reads on four dimensions, and every move below maps to one: problem solving (did you reach a durable job store plus a scheduler/queue/worker split that fires on time and runs reliably), communication (did you say why you separate deciding-when from running-the-work in a sentence), technical execution (a next_run_time index, visibility-timeout leases, idempotency keys, leader election or sharding, a dead-letter queue), and time management (did you spend your 60 minutes on the job store, the execution loop, and double-firing, not on the cron-expression parser). Reading a walkthrough teaches the design; it does not teach you to deliver it on the clock while someone pushes back.
Step 1 — Requirements: what a job scheduler must do
Spend the first few minutes here. Jumping to boxes before you have scoped the problem is the fastest way to design the wrong system.
| Type | Requirement |
|---|---|
| Functional | Submit a job to run once at a set time or on a recurring schedule (cron); run each due job at (close to) its scheduled time; run it reliably even when a worker crashes; cancel a job and query its status; scale horizontally as the number of jobs grows. |
| Functional (optional) | Exactly-once execution, job priorities, dependencies between jobs (DAGs), per-job timeouts — call these out, then scope most of them for later. |
| Non-functional | High throughput (millions of scheduled jobs; thousands firing per second at peak); durability (no job lost on a crash); reliable execution (every due job runs at least once); timing accuracy (fire close to the scheduled second); high availability; horizontal scalability. |
The sentence that earns points: "The hard part isn't parsing cron — it's firing millions of durable jobs on time and running each one reliably when workers crash, without running it twice — so the core of the design is a durable job store plus a scheduler that only enqueues due jobs and a separate worker pool that executes them, not a single cron loop." Say that and you have framed the entire design before drawing a box.
Step 2 — Capacity estimation (back of the envelope)
Estimation here is about proving one thing: the hard axis is durable scheduling plus reliable execution, not clever timer logic.
| Quantity | Assumption | Result |
|---|---|---|
| Scheduled jobs | millions, many recurring | can't hold in memory — a durable store on disk, indexed by next run time |
| Jobs due per second at peak | thousands, and spiky (many share a round time) | batch and enqueue; the scheduler must not block on a tight loop |
| Execution time | seconds to minutes, highly variable | decouple deciding-when from running via a queue, so slow jobs don't delay firing |
| Retry amplification | failures retry with backoff, up to N times | real executions exceed scheduled jobs; size the queue and workers for retries, not just first tries |
Two conclusions fall out, and you should say both out loud. First, the durable job store is the system — because there are millions of jobs and many recur, you cannot keep the schedule in memory; it lives on disk, indexed by next_run_time, and that index is how the scheduler finds what is due without scanning everything. Second, because executions are slow, variable, and amplified by retries, you cannot run the work inside the scheduler; you decouple deciding-when from running via a task queue, so a burst of slow jobs never delays the next thing that is due. You have now pointed the interview at the real deep dives — the durable store and the execution loop — before anyone asked.
Step 3 — API and data model
The API is deliberately small: submit a job, cancel it, and ask where it is.
| Operation | Shape |
|---|---|
submit(job_spec, run_at or cron) |
register a one-off (run_at) or recurring (cron) job; returns a job_id. The store computes the first next_run_time. |
cancel(job_id) |
mark the job cancelled so the scheduler skips it, and stop any future recurrences. |
get_status(job_id) |
return the job's state (scheduled / enqueued / running / succeeded / failed / dead-lettered) and its attempt history. |
The data model is thin. The job row is the unit the scheduler indexes, and a separate run record tracks each attempt so a retry never loses history:
| Entity | Notes |
|---|---|
job |
job_id, payload, next_run_time (indexed), schedule (cron or one-off), status, attempts, idempotency_key. |
run / attempt
|
one record per execution attempt: job_id, attempt_no, worker_id, lease_expiry, started_at, outcome. |
Step 4 — High-level design: scheduler, queue and workers
Now the boxes. A submit service writes a job to a durable job store; a scheduler polls the store for jobs whose time has come and pushes them onto a task queue; a pool of workers leases jobs off the queue, runs them, and acknowledges. Around that spine sit the pieces that make scaling and reliability work:
-
Submit / API service: validates a job and writes it to the store with its first
next_run_time; also servescancelandget_status. -
Durable job store: a sharded database holding every job, indexed by
next_run_time, partitioned by time bucket or shard so no single poller is a bottleneck; the source of truth for when and what. -
Scheduler service: polls for jobs due now (
next_run_time <= now), marks each as enqueued, and pushes it to the queue; runs as a leader with standbys, or sharded so each owns part of the schedule. It never runs the work. - Task queue: buffers due jobs between the scheduler and the workers, so a burst of due jobs or slow executions never blocks scheduling.
- Worker pool: leases a job with a visibility timeout, executes it, acknowledges on success, retries with backoff on failure, and autoscales off queue depth.
- Dead-letter queue + status/history store: jobs that fail past N retries land in a dead-letter queue for inspection; a status store records every attempt and the job's current state.
Say explicitly that the scheduler never runs the work — it only moves a durable job from "scheduled" to "enqueued," and all the execution reliability lives in the queue, the lease, and idempotency. Naming that split unprompted is a signal the interviewer is listening for.
Deep dive: the durable job store and firing on time
This is the first decision that actually decides the round. Where do the jobs live, and how does the scheduler fire them at the right second without melting down?
Every job is persisted in the durable job store with a next_run_time, and that column is indexed. The scheduler's job is to repeatedly ask the store "what is due now?" — a range query for next_run_time <= now — pull that batch, and enqueue it. Three choices fall straight out of that:
| Choice | Why it matters |
|---|---|
Index on next_run_time |
The scheduler finds due jobs with an indexed range scan, not a full table scan — it reads only the handful of rows whose time has come, so a poll is cheap even with millions of jobs. |
| Poll in batches, don't spin | The scheduler wakes on a short interval (or sleeps until the next due time), pulls a batch of due jobs, and enqueues them. A tight in-memory while(true) loop over every job burns CPU and can't survive a restart, because that schedule lives only in memory. |
| Partition the schedule | Shard the store by time bucket or job-id hash so several pollers each own a slice; one poller over the whole schedule is a bottleneck and a single point of failure. |
For sub-second precision you can layer a hierarchical time wheel or timer service in front of the store — jobs due soon are loaded into an in-memory wheel for exact firing, while the durable store stays the long-horizon source of truth. But the durable, indexed store is what makes the schedule survive a restart; the time wheel is only an accelerator over it.
"10,000 jobs are all due at exactly 09:00:00 — what happens, and does the scheduler block?" It does not fire them one synchronous call at a time. The poll returns the batch of due rows, the scheduler marks them enqueued and pushes them onto the queue in bulk, and the workers drain the queue at their own rate — so the spike becomes queue depth, not a stalled scheduler. Decoupling firing from running is exactly what absorbs the thundering herd.
System Design
HLD · Infrastructure · 60 min
Design a distributed job scheduler out loud, with follow-ups
Deep dive: reliable execution with leases and idempotency
This is the decision most candidates under-think. Enqueuing a due job is easy; the follow-up is always "a worker picks up the job and dies mid-run — now what?"
Workers lease, they don't just take. When a worker pulls a job off the queue it acquires a lease with a visibility timeout: the job becomes invisible to other workers for, say, 30 seconds. The worker runs the job and, on success, acknowledges it, which removes it from the queue. If the worker crashes before acking, the lease expires, the job becomes visible again, and another worker picks it up — nothing is lost.
Failures retry with backoff. A job that runs and fails is retried, each time after an exponentially growing delay, up to N attempts. After the last failed attempt it is moved to a dead-letter queue rather than retried forever, so one poison job can't jam the pipeline — and an operator can inspect it.
Exactly-once is impractical, so aim for at-least-once plus idempotency. Because a crash between "work done" and "ack sent" forces a redelivery, a job can run more than once. You cannot cheaply guarantee exactly-once end to end, so the honest design is at-least-once execution with idempotent jobs: each job carries an idempotency key, and the work is written so that running it twice has the same effect as running it once (upsert by key, or check-then-skip if the work is already done).
| What happens | Outcome |
|---|---|
| Worker runs the job and acks | Success — the job is removed from the queue and marked succeeded; the normal path. |
| Worker crashes before acking | The lease expires, the job reappears, another worker runs it — so it may run twice; idempotency makes the second run a no-op. |
| Job runs but keeps failing | Retried with exponential backoff up to N times, then moved to the dead-letter queue for inspection — never retried forever. |
The honest interview answer is: default to at-least-once and make jobs idempotent; treat exactly-once as a costly ideal you approximate, not a free guarantee.
"A worker crashes mid-job — does the job run twice, or never?" Neither "never" nor uncontrolled: the lease expires and the job is redelivered, so it runs again — at least once. It won't be lost, because the ack is what removes it from the queue and the crash meant no ack was sent. Whether the re-run is safe is the whole reason jobs carry an idempotency key and are written to be idempotent.
System Design
HLD · Infrastructure · 60 min
Defend leases, retries and idempotency out loud
Deep dive: avoiding double-scheduling and scaling
Leases and idempotency decide whether a job runs reliably once it is enqueued; this decides whether it gets enqueued exactly once in the first place. The follow-up is always the high-availability case.
Run one scheduler and it is a single point of failure; run several for availability and two of them can poll the same due job and enqueue it twice. There are three standard moves, and a strong answer layers them:
| Approach | How it stops double-firing |
|---|---|
| Leader election | One scheduler is elected leader (via a consensus service or a lock) and does all the polling; the others are hot standbys that take over only if the leader dies. Simple, but the single leader caps scheduling throughput. |
| Shard the schedule | Partition the schedule space (by job-id hash or time bucket) across schedulers so each owns a disjoint slice; no two schedulers ever look at the same job, so there is no overlap, and scheduling throughput scales with shards. |
| Fence each job row | Belt-and-braces: when a scheduler claims a due job it does a compare-and-set on the row's status (scheduled -> enqueued) guarded by a fencing token; only one writer wins, so even a racing or zombie scheduler can't enqueue the same job twice. |
Workers scale independently. Executors are stateless and autoscale off queue depth — the queue backing up means add workers, it draining means remove them. Because the scheduler only enqueues and the workers only execute, you scale the two axes separately: shard the scheduler for more scheduling throughput, add workers for more execution throughput.
"You run three scheduler nodes for HA — how do you stop a job firing three times?" Either elect one leader so only one node polls, or shard the schedule so each node owns a disjoint slice and they never see the same job. Back it with a compare-and-set on the job row's status from
scheduledtoenqueued, guarded by a fencing token, so even a slow or zombie node that wakes up late loses the race and can't double-enqueue.
The follow-ups that decide the job scheduler round
Scan the probes from each deep dive and they rhyme. Master these five and you are ready for a version of the question that is not drawn exactly like this one.
- "Why separate the scheduler from the workers — why not run the job when it fires?" → Running slow, variable, retry-amplified work inside the scheduler blocks the next due job; a durable store plus a queue keeps firing cheap while execution scales independently. (communication)
- "A worker crashes mid-job — does it run twice or never?" → At-least-once: the lease expires and the job is redelivered, so it runs again, never lost; idempotency keys make the re-run safe. (problem solving)
-
"10,000 jobs are due at the same second — does the scheduler block?" → No — an indexed
next_run_timerange query returns the batch, the scheduler bulk-enqueues it, and workers drain the queue; the spike becomes queue depth. (technical execution) - "Three scheduler nodes for HA — how do you stop a job firing three times?" → Leader election, or shard the schedule so each node owns a disjoint slice, plus a compare-and-set on the job row guarded by a fencing token. (technical execution)
- "A job keeps failing — what stops it retrying forever?" → Exponential backoff up to N attempts, then move it to a dead-letter queue for inspection, so one poison job can't jam the pipeline. (problem solving)
Each maps to a debrief dimension. Reading this list teaches you the answers; it does not teach you to deliver them while someone interrupts — that only comes from saying them out loud.
How to prepare: a one-week plan
You do not need fifty systems. You need this one cold and the framework automatic, so the next "design X" is just new nouns in the same five steps.
- Days 1–2: Learn the five-step framework until it is reflex. Do the job scheduler end to end on paper — requirements, estimate, API, data model, the scheduler-queue-workers boxes.
-
Days 3–4: Drill the durable-store deep dive: the
next_run_timeindex, batch polling instead of a tight loop, partitioning the schedule, and the time-wheel accelerator. Say each tradeoff in one sentence. - Days 5–6: Own execution and HA — leases and visibility timeouts, at-least-once plus idempotency, retries with backoff and the dead-letter queue, leader election vs sharding and fencing tokens. Answer each of the five follow-ups out loud.
- Day 7: A full timed 60-minute round, narrating the whole way, with someone (or something) interrupting. This is the rep that counts.
This post is the deep walkthrough behind one row of the broader Top 25 system design interview questions — once the job scheduler is automatic, the same spine carries the rest of the list.
Frequently asked questions
How do I design a distributed job scheduler step by step?
Follow the five-step HLD framework. Clarify requirements (submit one-off and recurring jobs, fire them on time, run reliably through worker crashes, cancel and query status, scale horizontally); estimate to show the hard axis is durable scheduling plus reliable execution, not timer logic; define the small API (submit with run_at or cron, cancel, get_status) over a job and run/attempt model; draw the design (submit service to a durable job store indexed by next_run_time, a scheduler that polls due jobs and enqueues them, a task queue, a worker pool that leases, runs and acks, plus a dead-letter queue and status store); then deep-dive the decisions that matter — the durable store and firing on time, reliable execution with leases and idempotency, and avoiding double-scheduling. Finish on the follow-ups about crashes and double-firing.
Why separate the scheduler from the workers?
Because the work a job does is slow, variable, and amplified by retries, while deciding when a job is due must stay cheap and fire on time. If the scheduler ran the work itself, a burst of slow jobs would delay everything else that is due. Separating them — a durable store and scheduler that only find due jobs and push them onto a queue, and a separate pool of workers that execute — keeps firing fast and lets you scale execution independently by adding workers. The scheduler only moves a job from scheduled to enqueued; all execution reliability lives in the queue, lease, and idempotency loop.
How do leases and visibility timeouts give at-least-once execution?
When a worker takes a job off the queue it gets a lease with a visibility timeout: the job is hidden from other workers for a fixed window while that worker runs it. On success the worker acknowledges the job, which removes it from the queue. If the worker crashes before acknowledging, the lease expires, the job becomes visible again, and another worker picks it up — so every due job runs at least once and none is lost. The cost is that a job can run more than once (a crash after the work but before the ack), which is why jobs must be idempotent.
What is the difference between at-least-once and exactly-once for jobs?
At-least-once means every job runs one or more times: a crash between finishing the work and acknowledging it forces a redelivery, so the job may repeat — which is why jobs carry an idempotency key and are written so a repeat is harmless. Exactly-once would mean every job runs precisely once regardless of failures, but guaranteeing it end to end across a crash is impractical and expensive. The honest design is at-least-once plus idempotent jobs, which gives the same observable result as exactly-once without the cost.
How do you stop multiple scheduler nodes firing the same job twice?
If several scheduler replicas poll the same store, two could pick up the same due job and enqueue it twice. Prevent it with leader election — one elected scheduler polls while the others stand by — or by sharding the schedule so each scheduler owns a disjoint slice of jobs and no two ever look at the same one. Back either with a compare-and-set on the job row's status from scheduled to enqueued, guarded by a fencing token, so even a slow or zombie scheduler that wakes late loses the race and cannot double-enqueue. Workers then scale separately off queue depth.
How do I practise a system design interview out loud?
Reading a walkthrough is not the same as defending a design while someone interrupts. On Zynter, the HLD track runs live voice rounds modelled on how real system design loops run — you talk through a shared design on the canvas, and a voice interviewer pushes with the follow-ups above one at a time, then gives you a debrief on problem solving, communication, technical execution, and time management. The score is a practice signal, not a hiring decision.
Practise it on Zynter
You can name every component. The round is won on firing on time and running reliably.
Pick the Distributed Job Scheduler round, draw the store-scheduler-queue-workers design on the canvas, and defend it out loud to a voice interviewer that keeps asking "a worker crashes mid-job — does it run twice or never?" and "three scheduler nodes — how do you stop a job firing three times?" — then read the debrief. Zynter.ai is on-demand mock interviews modelled on real company loops, with 150+ rounds across 20 companies including the HLD system design track (Sep 2026).
Practise the Job Scheduler round →
Browse all interview tracks




Top comments (0)