Engineering Atlas › System Design Interviews

Design a distributed job scheduler

19 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

Requirements

Functional:

Non-functional:

Out of scope: DAG dependencies between jobs (that is a workflow engine like Airflow or Temporal), running user code in sandboxes (we dispatch to the tenant's own workers or endpoints), and sub-second precision.

Back-of-envelope

Planning assumptions for a shared internal platform at a large company.

API

POST /v1/jobs                      Idempotency-Key: <uuid>
  {name, target:{type:"http"|"queue", ref}, payload,
   schedule:{runAt:"2026-10-16T09:00:00Z"} | {cron:"0 9 * * MON-FRI", tz:"Europe/London"},
   retry:{maxAttempts:5, backoffSeconds:10}, timeoutSeconds:300}
  -> 201 {jobId, nextRunAt}

DELETE /v1/jobs/{jobId}            -> 204
POST   /v1/jobs/{jobId}:pause      -> 200
GET    /v1/jobs/{jobId}/executions?cursor=...
  -> [{executionId, scheduledFor, attempt, state, startedAt, finishedAt, error}]

// worker protocol (gRPC)
Lease(pool, workerId, max, leaseSeconds) -> [{executionId, jobId, attempt, payload, leaseUntil}]
Heartbeat(executionId, attempt)          -> {leaseUntil} | LEASE_LOST
Complete(executionId, attempt, result)   -> OK | LEASE_LOST
Fail(executionId, attempt, error)        -> {retryAt} | DEAD

The attempt number is the fencing token. Every call from a worker includes it, and the server rejects calls whose attempt is no longer current.

Data model

A sharded relational store (Postgres, MySQL or a distributed SQL database) works well: the access patterns are point lookups and short range scans, and unique constraints do the deduplication. Cassandra also fits due_index well, but would need lightweight transactions for the dedup, which are slow.

High-level design

flowchart LR
  CL["Clients"] --> API["Job API"]
  API --> DB[("Jobs + due_index")]
  ETCD["etcd shard leases"] --> SCH["Scheduler shards"]
  SCH -- "scan next minute" --> DB
  SCH --> HEAP["In-memory min-heap"]
  HEAP -- "due now" --> RQ["Ready queues per pool"]
  RQ --> W["Workers"]
  W -- "lease, heartbeat, complete" --> EX["Execution service"]
  EX --> DB
  EX -- "retry or next cron run" --> DB
  EX --> DLQ["Dead letters"]

Write path. A client submits a job. The API validates the cron expression, computes the first next_run_at in the job's time zone, and in one transaction inserts the jobs row and its due_index row. If the due time is within the current minute, it also notifies the owning scheduler shard directly so short delays are not missed.

Dispatch path. Each scheduler instance owns a set of shards through etcd leases. For each shard, a loader runs every few seconds and reads due_index rows for the current and next minute into a min-heap keyed by next_run_at. A dispatcher loop peeks at the heap top, sleeps until it is due, then in one transaction inserts the executions row (the unique constraint deduplicates) and deletes the due_index row, and publishes the execution to the ready queue for the job's worker pool. Workers lease executions from the queue, heartbeat while running, and call complete or fail. Complete on a recurring job computes the next fire time and inserts a new due_index row; fail either inserts a retry row at now + backoff × 2^(attempt-1) or marks the execution dead.

Read path. Job and execution history reads go to replicas, paginated by time. Operators get per-tenant dashboards of lag (now minus scheduled_for at dispatch), failure rate and queue depth.

Deep dives

Finding due jobs

Options considered. First, poll the database: SELECT ... WHERE next_run_at <= now() ORDER BY next_run_at LIMIT 100 FOR UPDATE SKIP LOCKED from many dispatchers. It is simple and correct, and SKIP LOCKED lets dispatchers share a table without blocking each other; Postgres documents it as unsuitable for general queries, since skipped rows give an inconsistent view, but meant for exactly this case of many consumers on a queue-like table (Postgres docs). It works well up to perhaps a few thousand dispatches per second, then the index's hot head and constant polling dominate. Second, a Redis sorted set scored by due time with ZRANGEBYSCORE and a Lua script to claim. It is fast but holds durable state in an asynchronously replicated store. Third, a hierarchical timing wheel in memory, as Kafka uses for its delayed operations: Kafka replaced a DelayQueue with O(log n) insert and delete by timing wheels with O(1) cancellation, and in its benchmark the saturation point rose from about 25,000 to about 105,000 requests per second (Confluent). It is very cheap per timer, but has to be rebuilt from durable state after a crash.

Choice: durable due_index partitioned by shard and minute, plus an in-memory min-heap per shard holding only the next one to two minutes. The database does what it is good at (durable, range-scannable storage) and memory does what it is good at (precise ordering of a small, hot set). If a scheduler dies, another acquires its shards' leases and reloads from due_index; anything already dispatched is protected by the unique (job_id, scheduled_for) constraint. Dropbox's Async Task Framework is a production version of the same split: tasks sit in a store with a next-trigger time, a consumer polls for due ones every two seconds and pushes them onto per-callback SQS queues, and the target was 95% of tasks starting within five seconds of their scheduled time (Dropbox).

Leases, retries and duplicate execution

Exactly-once execution of side effects is not achievable when workers can stall: a worker pauses for a 40-second GC, its lease expires, the job is re-leased to another worker, and then the first worker wakes and finishes. So the contract is at-least-once, and the design makes duplicates harmless. This is the fencing-token argument: the lease alone cannot stop a paused process, so the resource it writes to must reject stale tokens (Kleppmann). Each attempt gets an incrementing attempt number. Re-leasing bumps it, and complete or heartbeat with an old attempt returns LEASE_LOST, so the stale worker's result is discarded. Downstream side effects get an idempotency key of job_id + scheduled_for, so a duplicate HTTP callback can be recognised by the receiver. Lease length is a trade-off: short leases recover crashed work quickly but force long jobs to heartbeat often; we default to 30 seconds with heartbeats every 10.

Retries use exponential backoff with jitter, capped at the job's maxAttempts, after which the execution moves to dead letters with its last error. A poison job that crashes its worker every time shows up as repeated lease expiries; counting those as attempts keeps it from looping forever.

Cron, time zones and the top-of-the-hour herd

Only the next occurrence of a recurring job exists in due_index. On completion (or on dispatch, for jobs that allow overlap) we compute the next fire time from the cron expression in the job's IANA time zone. Overlap is a per-job policy worth naming, as Kubernetes does with concurrencyPolicy: Allow (the default), Forbid (skip the new run while the old one is still going) or Replace (stop the old one); Kubernetes also only made .spec.timeZone stable in v1.27 and warns that a CronJob may create two Jobs or none for one slot, so jobs must be idempotent (Kubernetes docs). Daylight saving needs explicit rules: a 02:30 job on a spring-forward day has no 02:30, so we run it at the next valid instant; on fall-back day 01:30 happens twice, and we run it once. If the scheduler was down and several occurrences were missed, the default policy is to run once and skip the rest, with an opt-in "catch up" policy. Google's cron service leans the same way: when it cannot tell whether a launch happened, it prefers skipping a run to risking a double launch (Google SRE book, ch. 24). Kubernetes caps catch-up outright: a CronJob that has missed more than 100 schedules logs an error and does not start the Job.

For the herd, we offer and encourage a jitter window on cron jobs ("run within 5 minutes after 09:00"), hash the job ID into that window (Google added a ? to its crontab syntax that does this hashing, and EventBridge Scheduler exposes it as a flexible time window (AWS docs)), and enforce per-tenant dispatch quotas in the ready queues so one tenant with 500,000 jobs at midnight does not starve others. Ready queues are per worker pool, so a slow pool backs up only its own queue.

Down the stack

Layer 1The contract

An accepted job is durable before the API returns. Each scheduled occurrence is dispatched no earlier than its due time and, under normal load, within 2 seconds after it. Each occurrence executes at least once until it succeeds or exhausts its attempts. At any instant at most one attempt number is current for an execution, and the server accepts heartbeats and results only for the current attempt. Workers whose lease has expired may still be running; the contract makes their results void, not their code stop.

Layer 2The mechanism

Shard ownership uses etcd leases: a scheduler creates a lease with LeaseGrant and a TTL of, say, 10 seconds, writes /shards/17 -> instance-b attached to it, and keeps it alive over the LeaseKeepAlive stream. If it stops renewing, etcd deletes every key attached to the lease and another instance's watch fires on the delete event (etcd API). The etcd revision of the ownership key is passed as a fencing value with every dispatch transaction, and the dispatch is rejected if a newer owner exists. Inside a shard, the heap is a binary min-heap ordered by (next_run_at, sequence); push and pop are O(log n), and cancellations use lazy deletion: the heap entry carries a version and is skipped if the job's version has moved on. The dispatcher loop does not poll: it sleeps until the heap top's due time or until a new entry arrives on a channel, whichever comes first.

Layer 3The machine

A heap of 1M entries is about 20 levels deep. Each pop walks one root-to-leaf path, roughly 20 comparisons, and most levels miss in L1 and L2 cache, at around 5 to 100 ns per miss, so a pop costs on the order of 1 to 2 microseconds. One core can dispatch hundreds of thousands of heap operations per second; the real limit is the database insert per dispatch. Sleeping until a deadline maps to epoll_wait with a timeout or a timerfd on Linux, with wakeup precision in the tens of microseconds to low milliseconds depending on load. Clocks across machines are kept within a few milliseconds by NTP, small next to a 2-second target, but leases must not depend on synchronised clocks: etcd lease TTLs are measured on the etcd leader. Loading a minute's bucket is a range scan over adjacent B-tree leaf pages (8 KB in Postgres, 16 KB in InnoDB), so 10,000 due rows of about 60 bytes each read in roughly 75 to 150 pages, mostly sequential.

Why it's built this way

  1. Scanning millions of rows to find the few due now is wasteful, so we keep a time-ordered index and read only the next minute.
  2. Durable storage is too slow for per-job timers, and memory is lost on crash, so durable storage holds truth while memory holds a short-horizon heap rebuilt on failover.
  3. Workers can stall or die without telling anyone, so ownership must expire on its own: leases with deadlines, and attempt numbers to fence stale workers.
  4. Because leases expire while code may still be running, exactly-once is impossible, so we promise at-least-once and push idempotency to the edge.
  5. Humans schedule work at round times, so load is spiky by construction; jitter and quotas turn spikes into plateaus.

Failure modes

In the wild

Trade-offs

OptionStrengthsWeaknessesChoose when
DB polling with SKIP LOCKEDSimple, one system, transactionalPolling cost, few thousand per second ceilingUnder about 1,000 dispatches per second
Redis sorted setVery fast, easy to buildDurability depends on Redis persistence and failoverDelays are best-effort, loss is tolerable
Due index + in-memory heapDurable, precise, scales by shardMore moving parts: leases, loaders, rebalancingLarge shared platform (our choice)
Timing wheelO(1) per timer, huge timer countsCoarse granularity, needs rebuild after crashMillions of short timeouts in one process
Queue with delivery delay (SQS-style)No scheduler to runSQS caps delay at 15 minutes (AWS docs), no cronShort delays, no recurring jobs

Leadership lens

Check yourself

  1. A worker pauses for a 40-second GC, its lease expires, and the job is re-leased to another worker. What stops the first worker's late result from being recorded, and why can't the design promise exactly-once?

    Show answer

    Re-leasing bumps the attempt number, which acts as a fencing token, so the stale worker's complete or heartbeat returns LEASE_LOST and its result is discarded. Its code may already have produced side effects, though, so the contract is at-least-once and handlers dedupe with the job_id + scheduled_for idempotency key.

  2. Why does the design pair a durable due_index with a small in-memory min-heap per shard instead of using a Redis sorted set or polling the jobs table with SKIP LOCKED?

    Show answer

    Polling hits a ceiling of a few thousand dispatches per second as the hot index head and constant polling dominate, and Redis holds durable state in an asynchronously replicated store. The due index keeps truth durable and range-scannable by shard and minute, while the heap orders only the next minute or two and is rebuilt from the index after failover.

  3. Using the lesson's numbers, roughly how many jobs could be due in the same second at the top of the hour, and what does the design do about it?

    Show answer

    If 10% of the 40M daily recurring runs land on the 24 top-of-the-hour seconds, about 0.1 x 40M / 24, or roughly 170,000 jobs are due in one second. The design encourages a cron jitter window with the job ID hashed into it and enforces per-tenant dispatch quotas in per-pool ready queues, turning spikes into plateaus.

  4. A scheduler instance dies after dispatching some jobs. How does another instance take over without double-dispatching them?

    Show answer

    Its etcd shard leases expire in about 10 seconds, another instance acquires the shards and reloads upcoming minutes from due_index. Dispatch inserts the executions row under a unique (job_id, scheduled_for) constraint, so any occurrence the old owner already dispatched cannot be inserted again.

Sources and further study

Primary

Systems docs

Books

Other curricula

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →