Design a distributed job scheduler
Cron on one machine is a ten-line problem. Cron for millions of customers is not: thousands of jobs fall due in the same second at the top of every hour, machines die halfway through a run, two schedulers must never start the same job twice, and "9:00 every weekday in Mumbai" has to mean the right instant in every time zone. This page has enough material for a full 60–90 minute interview.
1. The prompt
"Design a service that lets customers schedule jobs: run this task at 3 pm tomorrow, or every hour, or every weekday at 9:00. We run the jobs for them on our own machines."
It sounds like a timer and a queue. What makes it hard is time (everything wants to run at :00, and time zones shift twice a year), duplicates (several schedulers and workers, any of which can die at the worst moment) and many tenants sharing one fleet. Products in this space include Kubernetes CronJobs, AWS EventBridge Scheduler, Google Cloud Scheduler, and internal platforms such as Dropbox's task scheduler and Slack's job queue.
2. Frame the problem
Spend the first five minutes asking questions. Each answer below changes something specific in the design. Try to guess the answer and its consequence before opening it.
One-off jobs, recurring jobs, or both?
Both: "at 15:00 on 18 Oct" and cron expressions like 0 9 * * MON-FRI.
Recurring jobs mean we must compute the next run after each one, in the job's time zone, and decide what happens when a run is missed.
What does a job actually do?
It runs a customer's container image with arguments on our worker fleet, for up to 15 minutes.
We run untrusted code, so we need sandboxing and resource limits. A cap on duration lets us size leases and timeouts. Longer, multi-step workflows are a different product (out of scope).
How punctual must a run be?
It should start within a few seconds of its time. Sub-second precision isn't needed.
"Within 5 s at p99" becomes our headline target, and it is hardest at the top of the hour.
How many jobs and runs?
About 50M active schedules and 20M runs a day. Many recurring jobs use round times like "every hour at :00".
The average is small, but the peak at :00 can be 1,000× the average. That decides the dispatch design.
Exactly once, or at least once?
At least once, with duplicates rare. A run must never execute twice at the same time. We give each run an id so customers can make their code idempotent.
True exactly-once execution of arbitrary code isn't possible across failures. Saying so, and offering idempotency support, is a senior signal.
What if a run fails?
Retry with backoff up to a configurable limit, then mark it failed and notify the customer.
We need attempt counters, backoff timers and a dead-letter state.
What if the previous run is still going when the next is due?
The customer chooses: skip the new run, queue it, or allow both.
This is an overlap (concurrency) policy per job. Kubernetes CronJobs have a similar setting, concurrencyPolicy.
Do jobs depend on each other (run B after A)?
Not in this version.
Dependency graphs turn a scheduler into a workflow engine (Airflow, Temporal). Keep it out and say why.
Is it multi-tenant?
Yes. Thousands of customers share the fleet, a few of them very large.
We need quotas and fairness so one customer can't starve the others, plus isolation between their code.
3. What it must do
- A customer can schedule a job: once at a time, or on a recurring cron schedule in a chosen time zone. They can pause, change or delete it.
- The system runs each due job on a worker, retrying failures with backoff and honouring the job's overlap policy.
- A customer can see each run: status, attempts, start and end times, and the exit result.
Not now (say these out loud, with a reason):
- Job dependencies and multi-step workflows: a different product, a workflow engine.
- Sub-second timing: nobody asked for it, and it would change the whole dispatch design.
- Streaming logs from running jobs: useful, but it's a logging system bolted on, not scheduling.
- Event-triggered jobs ("run when a file lands"): an easy extension once runs can be created by an API call.
4. How well it must do it
| Target | Why this number |
|---|---|
| On time: 99% of runs start within 5 s of their scheduled time, including the top of the hour speed · load shape | Customers schedule reports, billing runs and cache warmers at :00 and expect them then. The peak is the hard case, so the target names it. |
| Never lost: an accepted schedule survives any single failure, and every due run executes at least once data safety | A silently skipped payroll job is far worse than a late one. |
| Never twice at once: at most one active execution per run, even when schedulers or workers fail consistency | Concurrent duplicates corrupt customer data. Rare sequential duplicates after a crash are acceptable and visible (attempt 2). |
| Keeps scheduling during failures: losing any one scheduler, worker or zone delays runs by seconds, not minutes surviving failures | The scheduling API should favour availability; the claim step favours correctness. Name both. |
| Fair and isolated: one tenant's burst can't push another's p99 past the target, and tenants can't see or affect each other's jobs security · load shape | Multi-tenant platforms fail on noisy neighbours more often than on raw scale. |
5. Numbers that change the design
| Quantity | Working | → so we… |
|---|---|---|
| Average dispatch rate | 20M runs ÷ 86,400 s | ~230 a second: → so the average is easy; it's not what we design for |
| Top-of-the-hour peak | Say 25% of 20M daily runs are hourly jobs at :00 → 5M ÷ 24 ≈ 200,000 due in the same second | → so we must have runs loaded and workers ready before :00, because nothing can read and dispatch 200,000 rows from a database in 5 s |
| Schedules | 50M × ~1 KB | ~50 GB: → so one sharded relational database is plenty; the job table isn't the hard part |
| Run history | 20M runs a day × ~500 B, kept 90 days | ~900 GB: → so we partition runs by day and drop old partitions instead of deleting rows |
| Concurrent executions | Average 230/s × ~30 s per job ≈ 7,000; at :00, 200,000 start together | → so worker capacity follows the clock: pre-scale before the hour, and spread jobs that don't need :00 exactly |
Skip estimating API traffic for creating and listing jobs. It is tiny next to dispatch, and the interviewer gains nothing from the arithmetic.
6. The nouns
- Job: what to run and when (the schedule definition).
- Run: one occurrence of a job at one scheduled time, with its attempts.
- Tenant: the customer, with quotas.
- Worker: a machine (or slot on one) that executes runs.
- Lease: a time-limited claim of a run by a worker.
The fields that matter (others, like names and descriptions, are obvious):
jobs(job_id, tenant_id, schedule {cron | at}, time_zone, task {image, args, timeout},
retry {max_attempts, backoff}, overlap_policy, next_run_at, enabled, version)
runs(run_id, job_id, scheduled_for, status, attempt, lease_owner, lease_expires_at, fence, started_at, finished_at, result)
Note next_run_at: storing the next due instant (in UTC) is what makes "what's due now?" a simple indexed query. The cron text and time zone are kept to compute the one after.
7. The contract
REST for customers; gRPC between our own workers and the dispatcher, because it's internal, chatty and benefits from streaming and strict schemas. The tenant always comes from the API key or token, never from the request body.
POST /v1/jobs Idempotency-Key: 5f1c…
{schedule: {cron: "0 9 * * MON-FRI", time_zone: "Asia/Kolkata"} | {at: "2026-10-18T15:00:00Z"},
task: {image: "registry/acme/report:3", args: ["--daily"], timeout_s: 900},
retry: {max_attempts: 3, backoff: "exponential"}, overlap: "skip" | "queue" | "allow"}
→ 201 {job_id, next_run_at}
GET /v1/jobs/{job_id} → job, including the next 5 run times (computed)
PATCH /v1/jobs/{job_id} {enabled: false} | {schedule: …} (If-Match: version)
DELETE /v1/jobs/{job_id}
POST /v1/jobs/{job_id}/runs → run it now
GET /v1/jobs/{job_id}/runs?status=failed&cursor=… → {runs: [...], next_cursor}
// internal, worker ↔ dispatcher (gRPC)
Lease(worker_id, free_slots) → stream of {run_id, fence, task, lease_ttl}
Heartbeat(run_id, fence) → ok | lease_lost
Complete(run_id, fence, result) → ok | stale_fence
Data flow: (1) a job is saved with its next_run_at → (2) a scanner finds jobs falling due soon → (3) it creates a run and advances next_run_at → (4) at the scheduled time the run is handed to a worker under a lease → (5) the worker executes and heartbeats → (6) it reports the result, or the lease expires and the run is retried.
8. First working version
Build it one request at a time, with the simplest parts that work: stateless API servers, one relational database, one scheduler process, a queue and a pool of workers. Write down what you'll fix later and keep going.
8.1 Creating a job
The API validates the cron expression and time zone, computes the first next_run_at in UTC, and inserts the job. The idempotency key makes a retried "create" return the same job instead of creating two.
8.2 Finding due jobs
Once a second, a scheduler runs one transaction: select jobs with next_run_at <= now(), insert a run for each, and move each job's next_run_at forward. Because the run insert and the advance happen together, a crash can't create a run twice or skip one. Run ids then go onto a queue.
BEGIN;
SELECT job_id, next_run_at FROM jobs WHERE enabled AND next_run_at <= now() ORDER BY next_run_at LIMIT 1000 FOR UPDATE;
INSERT INTO runs (run_id, job_id, scheduled_for, status) VALUES (…, 'queued') ON CONFLICT (job_id, scheduled_for) DO NOTHING;
UPDATE jobs SET next_run_at = next_after(cron, time_zone, next_run_at) WHERE job_id IN (…);
COMMIT;
The unique key (job_id, scheduled_for) is a second safety net: whatever happens, one job can have only one run per scheduled time.
8.3 Running a job
A worker takes a run id from the queue, marks it running, starts the container with a timeout, and writes the result. A failure schedules attempt 2 after a backoff delay. When the run finishes, the API can show it.
8.4 Viewing runs
Reads go to the runs table, filtered by job and paged with a cursor on (scheduled_for, run_id). A read replica serves the dashboard, so heavy listing never slows the scheduler.
Parked for later (we'll fix each in section 9)
- One scheduler is a single point of failure, and two would dispatch the same job twice.
- Polling 1,000 rows a second cannot start 200,000 runs at 09:00:00.
- A worker that dies mid-run leaves its run stuck as "running" forever.
- Endless retries of a broken job waste capacity; a stuck job can run past its next run.
- One big tenant can fill the queue and delay everyone.
- Time zones and daylight saving time: some local times happen twice, some never.
9. Make it hold up
9.1 More schedulers, never a double dispatch never twice · surviving failures
We need several scheduler processes for availability and throughput, but each due job must be picked up by exactly one of them.
| Approach | Why | |
|---|---|---|
| ✗ Bad | Run several identical schedulers that each poll the whole table | Two of them read the same due row in the same second and both create a run. The unique key saves us from duplicate rows, but they still fight over every row. |
| ~ Good | One active leader chosen with a lease in etcd or ZooKeeper (Module 21); the others stand by | Correct and simple. But a single leader caps throughput, and a failover gap of a few seconds lands right on top of a :00 peak. |
| ✓ Great | Partition the jobs into, say, 1,024 shards by hash of job_id. Each scheduler holds leases on a set of shards (rebalanced when nodes join or leave). Within a shard, claim rows with FOR UPDATE SKIP LOCKED so even an overlap during rebalancing can't double-claim. | Throughput grows with schedulers, a failed node's shards move to others within a lease timeout, and the database guarantees the claim. This is defence in depth: shard leases, row locks and the unique run key. |
SKIP LOCKED lets each claimer skip rows another transaction has already locked, instead of waiting for them. It turns a table into a safe work queue for many consumers.
9.2 Being on time at the top of the hour on time
The numbers said 200,000 runs fall due in one second. A database poll that handles 2,000 rows a second would still be dispatching the 09:00 jobs at 09:01:40. The fix is to do the slow work early.
| Approach | Why | |
|---|---|---|
| ✗ Bad | Poll the database for due jobs and dispatch them as they're found | Dispatch speed is capped by the poll. Lateness grows with the size of the spike (see the lab). |
| ~ Good | Look ahead: every few seconds, load the runs due in the next 5 minutes into a fast delay queue (a Redis sorted set scored by due time, or an in-memory timing wheel per shard). At the due second, pop them at tens of thousands a second. | The database is read steadily over five minutes instead of all at once. But the workers still have to be there at 09:00. |
| ✓ Great | Look ahead plus capacity planning against the clock: scale the worker pool up a few minutes before each hour, and let jobs that don't need an exact time be spread across the minute by a hash of their id | Pre-scaling handles the real :00 work; spreading flattens the rest. Jenkins documents the same idea: its cron syntax has an H ("hash") symbol so many daily jobs don't all start at midnight. |
Try all three in the lab in section 13. Watch how the waiting backlog (red) collapses as you add the look-ahead, then ready workers, then spreading.
9.3 Workers that die, and zombies that come back never lost · never twice
- Leases, not "running" flags: a worker owns a run only until
lease_expires_at(say 30 s), and renews it with heartbeats every 10 s. If heartbeats stop, a reaper finds the expired lease and puts the run back for attempt 2. Nothing stays stuck. - Fencing tokens: every claim increments
fence. A worker that was only paused (a long garbage-collection pause, a network partition) and wakes up later still holds the old fence. Its heartbeat and completion are rejected (stale_fence), so a zombie can't overwrite the real result. This is the same idea as fencing for distributed locks in Module 21. - Kill on lease loss: when a heartbeat says
lease_lost, the worker stops the container. Together with fencing, this keeps the promise "never twice at the same time". - Give the job its identity: pass
run_idandattemptto the customer's code as environment variables, so it can make side effects idempotent (for example, "write report for run 7731" instead of "append a report").
9.4 Retries, timeouts and overlap never lost · fair
- Backoff with jitter: attempt n waits about
base × 2ⁿplus a random amount, so a downstream outage doesn't cause synchronised retry storms (Module 24). - Classify errors: a non-zero exit or a timeout is retryable; "image not found" or "permission denied" is not. Don't waste three attempts on a typo.
- Dead letter: after the last attempt, mark the run failed, notify the tenant (webhook or email), and keep it visible with its attempts.
- Timeouts: each job has a maximum duration. The worker kills the container at the limit and reports a timeout.
- Overlap policy: when a run falls due while the previous run of the same job is active, apply skip (record "skipped"), queue (start when the previous finishes) or allow. Kubernetes CronJobs offer a close cousin: Allow, Forbid (skip the new run) and Replace (cancel the running one and start the new one).
9.5 Fairness and isolation between tenants fair and isolated
| Approach | Why | |
|---|---|---|
| ✗ Bad | One shared first-in, first-out queue | A tenant that schedules 1M jobs at midnight fills the queue, and everyone else waits behind it. |
| ~ Good | Per-tenant concurrency quotas and rate limits at the API (Design D02) | Caps the damage, but quotas sized for peaks leave capacity idle the rest of the time. |
| ✓ Great | Weighted fair dispatch: per-tenant ready queues, served round-robin (weighted by plan); quotas on concurrent runs; separate worker pools for the largest tenants | Everyone gets a share during contention, and spare capacity is still used by whoever has work. |
Isolation of code matters as much as fairness of time. Run each job in a sandbox with CPU, memory, disk and network limits: containers for trusted internal jobs, lightweight virtual machines (Firecracker, which backs AWS Lambda, is one example) for untrusted customer code. Give each run short-lived credentials scoped to that tenant.
9.6 Time zones, daylight saving and missed runs on time · never lost
- Store the rule, not just the instant: keep the cron text and an IANA time zone ("Asia/Kolkata", "America/New_York"). Compute
next_run_atin that zone, then store it in UTC. - Daylight saving: in New York, 02:30 doesn't exist on the spring-forward day and 01:30 happens twice in autumn. Pick and document a rule: run a skipped time at the next valid instant, and run a repeated time once (on its first occurrence).
- Missed runs: if the service was down from 09:00 to 09:20, what about the 09:00, 09:05 and 09:10 runs of a five-minute job? Let the job choose a misfire policy: run once now, run every missed one, or skip to the next. Kubernetes has a similar knob,
startingDeadlineSeconds: "too late to bother, wait for the next one". - Clocks: the database's clock decides what is due, not each scheduler's clock; servers run NTP. Small skew only shifts a run by milliseconds.
9.7 The whole design after the deep dives
This is the picture to leave on the whiteboard. Follow one 09:00 run through every part: sharded schedulers holding leases, a look-ahead delay queue, fair dispatch, leased and fenced workers, a reaper, and capacity planned from the schedule itself.
10. Interviewer follow-ups
These are the "what if" questions you should expect after the main design. Answer each in under a minute: name the problem, give the mechanism, state the cost.
Traffic grows 10×. What breaks first?
The :00 peak, not the database. Dispatch then needs ~2M runs ready in the same second. Add scheduler shards and delay-queue partitions (both scale out by shard), pre-scale more workers, and push harder on spreading: make "any time in this minute" the default for new hourly jobs. The jobs table grows to ~500 GB and still shards easily by job id.
A whole region goes down.
Run the service in at least two regions, with each job owned by a home region. The jobs database replicates to the other region. On failover, the standby region takes over the shard leases and applies each job's misfire policy for the gap. Runs in flight in the dead region are retried after their leases expire, so a few runs execute twice. That's why we pass run_id for idempotency.
A customer needs a job to run exactly once (it moves money).
Be honest: we can guarantee at-most-one concurrent execution and at-least-once completion, not exactly-once side effects in their systems. Exactly-once effect needs their cooperation: they use run_id as an idempotency key with their payment provider (Design D16). We can also offer an "at most once" mode that never retries after a lost worker, at the cost of possibly skipping a run.
A job usually takes 2 minutes but today takes 70, and it runs every hour.
That's the overlap policy in action: with skip, the next hour's run is recorded as skipped. We also alert the tenant when a run exceeds, say, 3× its typical duration, and the job's timeout ends it if the tenant set one.
One tenant creates a million one-off jobs for midnight.
The API's per-tenant quota on jobs per minute stops an accidental million-job loop at creation time. For legitimate bulk loads, weighted fair dispatch means other tenants keep their share at midnight while this tenant's jobs drain at its concurrency quota. We'd also suggest spreading them.
The jobs database is down for 10 minutes.
The delay queues already hold the next 5 minutes of runs, so dispatch keeps going for that long. Run results are buffered by workers and written later. After 5 minutes, new runs can't be created, so runs start late, then catch up by each job's misfire policy once the database returns. The scheduling API returns errors for writes (it chose correctness over accepting writes it can't store).
How do you deploy a new scheduler version without missing runs?
Rolling deploy: a node being stopped releases its shard leases cleanly, and the others pick them up within seconds. Its delay-queue contents are rebuilt by the new owner from the database (runs are idempotent by the unique key). Never deploy in the minutes around :00. The deploy tool can enforce that window.
Show customers the next five run times.
Compute them on read from the cron expression and time zone. It's cheap, and storing them would mean updating five rows whenever a schedule changes. Use the same function the scheduler uses, so the preview never disagrees with reality (including daylight-saving rules).
A customer edits a schedule while a run is in progress.
The edit bumps the job's version and recomputes next_run_at. The run in progress continues with the definition it started with (its task spec was copied into the run). Runs already loaded into the delay queue for the old schedule are re-checked against the job version at dispatch, and stale ones are dropped.
Most workers sit idle 50 minutes of every hour. Can we cut cost?
Yes. Autoscale on a forecast (the schedule tells us exactly what's coming), use spot or preemptible capacity for jobs that tolerate interruption, spread flexible jobs, and pack small jobs densely. The scheduler knows the future load better than any reactive autoscaler, so publish "runs due per minute" to the capacity planner.
Now they want jobs to depend on each other.
That's a workflow engine: a directed graph of steps, state per step, and "start B when A succeeds". Build it as a layer on top: a workflow service creates runs through our API when their dependencies finish, or adopt a workflow engine such as Temporal or Airflow. Keep the scheduler simple and fast.
11. When things break
| Failure | What users see | How we detect it | What limits the damage |
|---|---|---|---|
| A scheduler node dies | Runs in its shards start a few seconds late | Shard lease expires; dispatch-lag alert | Other nodes take the shards; delay queue rebuilt from the database |
| A worker dies mid-run | The run shows attempt 2, starting ~30 s later | Missing heartbeats; lease expiry | Reaper re-queues; fencing rejects the old worker if it returns |
| Delay queue (Redis) lost | Brief extra lateness | Queue depth drops to zero unexpectedly | Runs are recreated from the database; the unique key prevents duplicates |
| Database primary fails | Writes fail for ~30 s; dispatch continues from the look-ahead | Health checks; failover event | Replica promoted; look-ahead window covers the gap |
| Bad image or bug in a tenant's job | That job fails and retries | Failure rate per job and tenant | Non-retryable classification; dead letter; notify tenant |
| Retry storm after a downstream outage | Slow recovery | Retry rate spikes | Exponential backoff with jitter; per-tenant concurrency caps |
| Bad scheduler deploy (wrong next-run maths) | Runs at the wrong times | Dispatch-lag and "runs per minute vs forecast" alerts; canary shards | Canary on a few shards first; roll back; replay from run records |
12. Wrap-up and the clock
Trade-offs we made:
- At-least-once with fencing, instead of pretending to offer exactly-once.
- A relational database for jobs and runs (transactions, unique keys), and a separate fast delay queue for timing.
- Correctness on the claim, availability on the API.
- Spreading flexible jobs, at the cost of "exactly :00" for those who don't need it.
What we'd watch:
- Dispatch lag p99 (start time minus scheduled time) per region and tenant: the headline target.
- Runs waiting for a worker.
- Lease expiries per minute (dying workers).
- Failure and retry rates per tenant.
- Dead-letter count.
At 10×:
- More shards everywhere.
- Forecast-driven pre-scaling.
- Spreading on by default.
- Regional ownership of jobs.
A 60-minute interview
| 0–5 | Frame the problem (questions in §2) |
| 5–10 | Must do, how well, and the :00 number |
| 10–15 | Nouns and contract |
| 15–30 | First working version, 8.1–8.4, with the parked list |
| 30–50 | Deep dives: double dispatch (9.1), the :00 peak (9.2), leases and fencing (9.3) |
| 50–60 | Follow-ups the interviewer picks; wrap up |
A 90-minute interview
| 0–8 | Frame the problem, including tenants and overlap |
| 8–15 | Must do, how well, numbers |
| 15–22 | Nouns, contract, data flow |
| 22–40 | First working version |
| 40–75 | Deep dives 9.1–9.6, interviewer steering |
| 75–85 | Follow-ups: region loss, 10×, exactly-once |
| 85–90 | Trade-offs, metrics, 10× plan |
| Level | What a strong answer shows |
|---|---|
| Mid-level | A complete first version: jobs table with next_run_at, a polling scheduler with a transaction, a queue, workers and retries. Recognises that two schedulers could double-dispatch and proposes a lock or leader. |
| Senior | Leads the deep dives: sharded schedulers with SKIP LOCKED, the :00 spike with a look-ahead delay queue, leases with heartbeats and fencing, backoff with jitter, overlap policy, honest at-least-once semantics. |
| Staff+ | Frames the product's guarantees (what "exactly once" can and can't mean), designs for tenants (fair dispatch, sandboxing, quotas), capacity against the clock and forecasting, multi-region ownership and misfire policy, safe deploys around :00, and what to measure. |
Common mistakes:
- Designing for the average (230/s) instead of the peak (200,000 at :00).
- Saying "exactly once" without explaining how.
- Using a "running" flag with no lease, so dead workers leave runs stuck.
- Locks without fencing.
- Ignoring time zones and daylight saving.
- Turning the scheduler into a workflow engine nobody asked for.
13. Try it, check yourself, read more
Top-of-the-hour lab. A burst of jobs falls due at 09:00:00. Choose how runs reach the workers, how many worker slots are ready, and whether flexible jobs are spread across the minute. Each job runs for 20 s. The red area is runs waiting; teal bars are runs starting each second.
What does SELECT … FOR UPDATE SKIP LOCKED give a scheduler?
200,000 runs are due at 09:00:00. What keeps them on time?
A paused worker wakes up after its lease expired and another worker took the run. What stops it?
What execution guarantee can a scheduler honestly offer for arbitrary code?
How should "every weekday at 09:00 in New York" be stored?
| Resource | Why |
|---|---|
| CronJob (Kubernetes docs) | Concurrency policy, starting deadlines and time zones in a real scheduler. |
| The locking clause (PostgreSQL docs) | FOR UPDATE, NOWAIT and SKIP LOCKED. |
| Asynchronous task scheduling at Dropbox (Dropbox) | A production task scheduler's design and guarantees. |
| Scaling Slack's job queue (Slack) | What breaks in a job system at scale, and how it was fixed. |