InterviewKata
System design · Worked design D21 of 46 · Data and infrastructure · Long form

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

  1. 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.
  2. The system runs each due job on a worker, retrying failures with backoff and honouring the job's overlap policy.
  3. 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):

4. How well it must do it

TargetWhy this number
On time: 99% of runs start within 5 s of their scheduled time, including the top of the hour speed · load shapeCustomers 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 safetyA 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 consistencyConcurrent 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 failuresThe 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 shapeMulti-tenant platforms fail on noisy neighbours more often than on raw scale.

5. Numbers that change the design

QuantityWorking→ so we…
Average dispatch rate20M runs ÷ 86,400 s~230 a second: → so the average is easy; it's not what we design for
Top-of-the-hour peakSay 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
Schedules50M × ~1 KB~50 GB: → so one sharded relational database is plenty; the job table isn't the hard part
Run history20M 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 executionsAverage 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

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)

  1. One scheduler is a single point of failure, and two would dispatch the same job twice.
  2. Polling 1,000 rows a second cannot start 200,000 runs at 09:00:00.
  3. A worker that dies mid-run leaves its run stuck as "running" forever.
  4. Endless retries of a broken job waste capacity; a stuck job can run past its next run.
  5. One big tenant can fill the queue and delay everyone.
  6. 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.

ApproachWhy
✗ BadRun several identical schedulers that each poll the whole tableTwo 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.
~ GoodOne active leader chosen with a lease in etcd or ZooKeeper (Module 21); the others stand byCorrect and simple. But a single leader caps throughput, and a failover gap of a few seconds lands right on top of a :00 peak.
✓ GreatPartition 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.

ApproachWhy
✗ BadPoll the database for due jobs and dispatch them as they're foundDispatch speed is capped by the poll. Lateness grows with the size of the spike (see the lab).
~ GoodLook 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.
✓ GreatLook 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 idPre-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

9.4 Retries, timeouts and overlap never lost · fair

9.5 Fairness and isolation between tenants fair and isolated

ApproachWhy
✗ BadOne shared first-in, first-out queueA tenant that schedules 1M jobs at midnight fills the queue, and everyone else waits behind it.
~ GoodPer-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.
✓ GreatWeighted fair dispatch: per-tenant ready queues, served round-robin (weighted by plan); quotas on concurrent runs; separate worker pools for the largest tenantsEveryone 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

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

FailureWhat users seeHow we detect itWhat limits the damage
A scheduler node diesRuns in its shards start a few seconds lateShard lease expires; dispatch-lag alertOther nodes take the shards; delay queue rebuilt from the database
A worker dies mid-runThe run shows attempt 2, starting ~30 s laterMissing heartbeats; lease expiryReaper re-queues; fencing rejects the old worker if it returns
Delay queue (Redis) lostBrief extra latenessQueue depth drops to zero unexpectedlyRuns are recreated from the database; the unique key prevents duplicates
Database primary failsWrites fail for ~30 s; dispatch continues from the look-aheadHealth checks; failover eventReplica promoted; look-ahead window covers the gap
Bad image or bug in a tenant's jobThat job fails and retriesFailure rate per job and tenantNon-retryable classification; dead letter; notify tenant
Retry storm after a downstream outageSlow recoveryRetry rate spikesExponential backoff with jitter; per-tenant concurrency caps
Bad scheduler deploy (wrong next-run maths)Runs at the wrong timesDispatch-lag and "runs per minute vs forecast" alerts; canary shardsCanary on a few shards first; roll back; replay from run records

12. Wrap-up and the clock

Trade-offs we made:

What we'd watch:

At 10×:

A 60-minute interview

0–5Frame the problem (questions in §2)
5–10Must do, how well, and the :00 number
10–15Nouns and contract
15–30First working version, 8.1–8.4, with the parked list
30–50Deep dives: double dispatch (9.1), the :00 peak (9.2), leases and fencing (9.3)
50–60Follow-ups the interviewer picks; wrap up

A 90-minute interview

0–8Frame the problem, including tenants and overlap
8–15Must do, how well, numbers
15–22Nouns, contract, data flow
22–40First working version
40–75Deep dives 9.1–9.6, interviewer steering
75–85Follow-ups: region loss, 10×, exactly-once
85–90Trade-offs, metrics, 10× plan
LevelWhat a strong answer shows
Mid-levelA 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.
SeniorLeads 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:

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?

Locked rows are skipped, so each claimer gets different due jobs.

200,000 runs are due at 09:00:00. What keeps them on time?

Do the slow reads early; at the due second only fast pops remain (plus enough ready workers).

A paused worker wakes up after its lease expired and another worker took the run. What stops it?

Every claim increments the fence; only the latest holder can write.

What execution guarantee can a scheduler honestly offer for arbitrary code?

Crashes force a choice between possibly retrying and possibly skipping; idempotent jobs make retries safe.

How should "every weekday at 09:00 in New York" be stored?

The UTC instant changes with daylight saving; the rule and zone don't.
ResourceWhy
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.