Design problems
Classic building blocksMediumSDE-2 35 min

Design a distributed job scheduler

Asked at
AmazonAtlassianGoogleMicrosoftRazorpaySwiggy

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

durable job store with next_run_at indexsharded schedulers with leases or SKIP LOCKEDunique (job_id, scheduled_for) to prevent duplicate runsqueue between scheduler and workersat-least-once execution with idempotency keysrun leases, heartbeats and reaperretries with exponential backoff and jitter, dead-lettertime zones and DST for croncatch-up policy for missed runsper-tenant fairness and rate limiting to targets

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

Average dispatch rate~230 runs/s20M runs/day ÷ 86,400 ≈ 231/s
Peak dispatch rate~100k runs in one second at 00:00 and on the hourMany cron jobs use '0 * * * *' or midnight; 1M recurring jobs with ~10% at the top of the hour
Pending one-off jobs100M rows, ~50 GB~500 B per job (ids, schedule, payload ref, status); payloads over 4 KB stored in S3
Run history~20M rows/day, ~3.6 TB/year20M × ~500 B = 10 GB/day; keep 30 days hot in DB, archive older runs to S3/warehouse
Scheduler partitions~64-256 shardsEach shard owner scans its slice every ~500 ms; 100k due jobs ÷ 128 shards ≈ 800 per shard per peak second, easy for one process

3 API

EndpointWhat it does
POST/v1/jobsCreate 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 CockroachDB
job_id (PK)tenant_idschedule_typecron_exprtimezonetargetpayload_refretry_policystatusnext_run_atshard_idversion

Index 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_aterror

Unique 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 SQS
run_idjob_idtargetpayload_refattemptdeadline

Separates scheduling from execution; per-tenant or per-priority queues give fairness.

shard leasesetcd / ZooKeeper
shard_id → scheduler instancelease

Each scheduler instance owns a set of shards; on failure the leases move to others.

payloadsS3
payload_ref → blob

Large payloads kept out of the hot DB rows.

5 Architecture

CLIENTSEDGESERVICESASYNCDATAEXTERNALcreate / manageinsert jobbig payloadsown shardsscan due, insert rundispatch runexecuteheartbeat, completeexpired leasesretry with backoffTeam services / UIAPI gatewayScheduler (shardowners)Lease reaper / retrierJob API serviceDispatch queues(Kafka)Worker fleetetcd shard leasesJobs + runs (Postgres)Payloads + archive(S3)Target endpoints
ClientEdge / gatewayService / workerQueue / streamCacheDatabase / storageAI / modelThird partyrequestasync (event / queue)

Hover a box to highlight its connections.

6 Request flows

Schedule and run a recurring job

  1. 1Team calls POST /v1/jobs with cron '0 2 * * *', timezone 'Asia/Kolkata', target = settlement-service HTTP endpoint.
  2. 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.
  3. 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.
  4. 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.
  5. 5After commit, it publishes the run to the dispatch queue for that tenant/priority (a transactional outbox makes this reliable).
  6. 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.
  7. 7The worker heartbeats every 20 s; on finish it marks the run succeeded or failed.
  8. 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

  1. 1Worker crashes during a run; heartbeats stop.
  2. 2The reaper finds runs with status running and lease_expires_at < now().
  3. 3It increments the attempt and re-queues the run (or marks it dead if attempts are exhausted).
  4. 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

Q1How would you support job dependencies (B runs after A succeeds)?
Q2How do you let a team cancel a run that is already executing?
Q3How would you guarantee that a job runs in a specific region for data residency?
Q4What metrics and alerts would you set up?
Q5How do you scale to 1M runs per second?

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.

Start free