Design a distributed job scheduler
The interviewer asks
“Design a service that lets internal teams schedule jobs: one-off jobs at a specific time (send this reminder at 9:00 tomorrow) and recurring jobs (cron, e.g. every night at 2 AM). It must run millions of jobs a day reliably, on time, and never lose a job.”
What a strong answer looks like
A strong answer splits the system into three parts: a durable job store (definitions and run records), a scheduler that finds jobs that are due and dispatches them, and a worker fleet that executes them via a queue. The hard parts are scanning for due jobs efficiently (index on next_run_at, partitioned by time bucket or shard), making sure each run is dispatched once even with many scheduler instances (partition ownership or SELECT ... FOR UPDATE SKIP LOCKED), and handling at-least-once execution honestly: workers crash, so jobs must be idempotent and runs have leases, heartbeats, retries with backoff and a dead-letter state. Senior candidates cover the thundering 'midnight' spike, time zones and DST, missed runs after an outage (catch-up policy), multi-tenant fairness, and when to use a durable workflow engine like Temporal instead.
Concepts to mention
1 Requirements
Functional
- Create one-off jobs to run at a given time and recurring jobs with cron expressions and a time zone
- Job payload: target (HTTP endpoint, queue topic or container image) plus arguments
- Retries with configurable policy (max attempts, exponential backoff), timeout per run
- Pause, resume, cancel and update jobs
- View run history: status, start/end time, attempts, error
- Catch-up policy per recurring job after downtime: run once, run all missed, or skip
Non-functional
- Scale: 100M scheduled one-off jobs pending, 1M recurring jobs, ~20M runs/day with peaks of 100k due at the same second
- Timeliness: 99% of runs start within 2 s of their scheduled time
- Durability: an accepted job is never lost
- No duplicate dispatch in normal operation; at-least-once execution under failures
- Availability 99.95% for scheduling and dispatch
- Multi-tenant: one team's flood of jobs must not delay other teams
Out of scope (say it out loud)
- Complex multi-step workflows with branching (hand off to a workflow engine like Temporal)
- The business logic of jobs
- Batch data processing frameworks (Spark, Airflow DAG semantics)
2 Estimates
3 API
| Endpoint | What it does |
|---|---|
POST/v1/jobs | Create job: {name, schedule: {at | cron, timezone}, target, payload, retry_policy, timeout_s, idempotency_key, catchup_policy}. |
PATCH/v1/jobs/{id} | Update schedule or payload; pause/resume. |
DELETE/v1/jobs/{id} | Cancel a job and any pending runs. |
GET/v1/jobs/{id}/runs?cursor= | Run history with status and errors. |
gRPCWorker.Heartbeat(run_id, lease_token) | Workers extend the run lease while working; returns cancel=true if the job was cancelled. |
gRPCWorker.Complete(run_id, lease_token, status, output) | Mark run succeeded or failed; the scheduler applies the retry policy. |
4 Data model
jobsPostgres (sharded by job_id) or CockroachDBjob_id (PK)tenant_idschedule_typecron_exprtimezonetargetpayload_refretry_policystatusnext_run_atshard_idversionIndex on (shard_id, next_run_at) WHERE status = 'active' powers the due-job scan. shard_id = hash(job_id) % 128.
runsPostgres (partitioned by day)run_id (PK)job_idscheduled_forattemptstatus (queued|running|succeeded|failed|dead)lease_ownerlease_expires_atstarted_atfinished_aterrorUnique constraint (job_id, scheduled_for) prevents two runs for the same scheduled time even if two schedulers race. Daily partitions are dropped after archive.
dispatch queueKafka or SQSrun_idjob_idtargetpayload_refattemptdeadlineSeparates scheduling from execution; per-tenant or per-priority queues give fairness.
shard leasesetcd / ZooKeepershard_id → scheduler instanceleaseEach scheduler instance owns a set of shards; on failure the leases move to others.
payloadsS3payload_ref → blobLarge payloads kept out of the hot DB rows.
5 Architecture
Hover a box to highlight its connections.
6 Request flows
Schedule and run a recurring job
- 1Team calls POST /v1/jobs with cron '0 2 * * *', timezone 'Asia/Kolkata', target = settlement-service HTTP endpoint.
- 2Job API validates the cron, computes next_run_at = 2026-10-26 02:00 IST (stored as UTC 2026-10-25 20:30), assigns shard_id, and inserts the row.
- 3Scheduler instance owning that shard scans every 500 ms: SELECT jobs WHERE shard_id IN (mine) AND next_run_at <= now() + 2 s ORDER BY next_run_at LIMIT 1000.
- 4For each due job, in one transaction it inserts a run (job_id, scheduled_for) and advances next_run_at to the next cron time; the unique key stops duplicates.
- 5After commit, it publishes the run to the dispatch queue for that tenant/priority (a transactional outbox makes this reliable).
- 6A worker takes the message, sets the run to running with a lease (expires in 60 s) and calls the target with header Idempotency-Key = run_id.
- 7The worker heartbeats every 20 s; on finish it marks the run succeeded or failed.
- 8On failure the retry policy schedules attempt 2 after backoff (e.g. 30 s, 2 min, 10 min with jitter); after max attempts the run is marked dead and the owner is alerted.
Worker dies mid-run
- 1Worker crashes during a run; heartbeats stop.
- 2The reaper finds runs with status running and lease_expires_at < now().
- 3It increments the attempt and re-queues the run (or marks it dead if attempts are exhausted).
- 4A new worker runs it with the same run_id as idempotency key, so a target that already finished the work can return the earlier result.
7 Deep dives
Finding due jobs efficiently
With 100M pending jobs you cannot scan the table. The core structure is an index on next_run_at, so 'jobs due before now' is a cheap range scan. To spread work across scheduler instances, give every job a shard_id (hash of job_id modulo 128) and index (shard_id, next_run_at). Each scheduler instance owns a set of shards via leases in etcd and only scans those, so no two instances look at the same rows. An alternative without leases is the Postgres pattern SELECT ... FOR UPDATE SKIP LOCKED LIMIT 500, which lets many schedulers pick disjoint rows from the same table; it is simple and works well up to thousands of dispatches per second per database. Scanning slightly ahead (now + 2 s) and holding runs in an in-memory timing wheel lets the scheduler release them exactly on time instead of waiting for the next poll. For very far-future one-off jobs (reminders in 6 months), a two-tier design helps: keep them in cold storage bucketed by hour, and a loader moves the next hour's jobs into the hot table, keeping the hot index small.
Exactly once is not possible: design for at-least-once
The scheduler can guarantee that each scheduled time produces exactly one run record, thanks to the unique constraint on (job_id, scheduled_for). It cannot guarantee that the job's side effect happens exactly once. A worker can call the target, the target can complete the work, and the worker can crash before it records success; the reaper will then retry. Any 'exactly-once' claim here is wrong. So the contract is: dispatch is de-duplicated, execution is at-least-once, and targets must be idempotent. Pass run_id (plus attempt) as an idempotency key, and have targets store processed keys with a unique constraint. Leases with heartbeats detect dead workers; choose lease length relative to job duration (60 s lease, 20 s heartbeat). For long jobs, heartbeats carry progress so a retry can resume. A worker that loses its lease (e.g. network partition) must stop, and its completion is rejected if the lease token no longer matches (fencing). This is exactly the same model as Temporal activities and SQS visibility timeouts, which you can mention as proof that the pattern is standard.
The top-of-the-hour spike, time zones and catch-up
Humans write '0 0 * * *' far more often than '17 3 * * *', so traffic spikes at midnight and on the hour; in India, many billing, settlement and reminder jobs fire at 00:00 IST. 100k runs due in the same second can overload the queue and, worse, the downstream services they call. Mitigations: let job owners declare a window ('any time between 2:00 and 2:30') and spread runs with deterministic jitter (hash(job_id) mod window); rate-limit dispatch per target service; and keep per-tenant queues so one team's 50k jobs do not delay another team's 10. Time zones need care: store the cron expression with an IANA time zone and compute the next run in that zone, then convert to UTC. India has no DST, but global customers do: a 2:30 AM job may not exist on spring-forward day and may happen twice on fall-back; document the rule (skip or run once). After an outage, recurring jobs have missed runs. A catch-up policy per job decides: run once now (most common), run every missed occurrence (for idempotent per-period work like hourly aggregation), or skip.
8 Trade-offs
Database polling vs delay queues
Polling a database index is simple, durable and easy to query for history, but adds up to one poll interval of delay and puts scan load on the DB. Delay queues (SQS delay up to 15 minutes, Redis sorted sets scored by time, Kafka with time-bucketed topics) push jobs at the right time but are limited in delay length or durability. Use the database as the source of truth and optionally a timing wheel or Redis sorted set as a near-term buffer.
Build vs use a workflow engine
For simple 'call this endpoint at this time', a custom scheduler is reasonable. For multi-step jobs with retries per step, compensation and human waits, Temporal or AWS Step Functions already solve durable state, timers and retries; building that yourself is months of work and hard to get right.
Push to workers vs workers pull
Pulling from a queue gives natural backpressure: workers take work only when they have capacity. Pushing (scheduler calls workers directly) lowers latency but needs load awareness and retries. Pull via queues is the safer default.
9 Bottlenecks
Single database for jobs and runs
At 10× load the runs table grows by 200M rows/day. Partition runs by day and archive to S3, shard jobs by job_id across several Postgres clusters, and keep the hot due-job index small with the two-tier hot/cold design.
Downstream overload
The scheduler can produce work faster than targets can absorb. Per-target concurrency limits and rate limits in the dispatcher, plus queue depth alerts, prevent the scheduler from DDoSing internal services at midnight.
10 Failure handling
Scheduler instance dies
Its etcd leases expire in ~10 s and other instances take over its shards. They scan from the oldest due job, so nothing is skipped; at worst some runs start ~10-15 s late. Runs already inserted are not duplicated because of the unique key.
Database unavailable
New jobs cannot be accepted and no new runs are dispatched; running jobs continue but cannot record completion. Use a replicated Postgres with automatic failover (~30 s). After recovery, due jobs are dispatched according to their catch-up policy.
Poison job
A job that always crashes its worker is retried up to the limit with backoff, then moved to dead state and alerted. Without max attempts it would loop forever and waste capacity.
11 What impresses
- States clearly that execution is at-least-once and designs idempotency into the contract
- Uses a unique constraint to make dispatch idempotent across racing schedulers
- Explains SKIP LOCKED or shard leases with their limits
- Addresses the midnight spike with jitter windows and per-target limits
- Handles DST and catch-up policy explicitly
- Knows when to stop building and use Temporal
12 Mistakes
- One scheduler process with no failover
- Running cron on every app server, causing each job to run N times
- Claiming exactly-once execution
- Scanning the full jobs table every second
- No timeouts or leases, so a dead worker's job is stuck forever
- Ignoring time zones
13 Follow-ups
Lessons this design uses
Saved on this device. Create a free account to keep it on every device.
Practise this with Sam
A real mock interview, out loud, with an honest scorecard. Free to start.