Infrastructure
Design Distributed Job Scheduler
01
What we are building
What we are building
Picture an internal platform that other teams call when they need something to happen later. The payments team wants every subscription renewal attempted at the exact minute it falls due. Marketing wants a reminder push sent at 8:00 local time in each of forty timezones. Data engineering wants a nightly export kicked off at 01:30, and a support tool wants to re-open snoozed tickets after three days. None of these teams want to run their own timer process, and none of them want to think about what happens when it crashes.
So the product is a trigger service. A caller registers a job with a schedule and a target, and we promise to deliver a trigger to that target at the right time, retry when it fails, and keep a history of every attempt. The work itself happens in the caller's systems: we call an HTTPS endpoint or publish a message onto a topic. Keeping execution out of scope is a deliberate choice, because it turns a general compute platform into a much smaller timing and delivery problem.
A single cron host is a single point of failure, and it has no memory of what it missed while it was down. Adding a second host for safety makes every job fire twice, which is worse for anything that moves money.
Load is wildly uneven. People schedule things on round numbers, so the first second of each hour can carry fifty times the average rate, and the first second of the day even more.
A dispatcher can fail after the target received the call but before we recorded success. From the outside those two cases look the same, so true exactly-once delivery is not available. We have to design around duplicates instead.
State the boundary early: we decide when and whether a trigger is sent, and we make duplicates rare and detectable. The target owns making its own handler safe to run twice. Saying this up front stops the conversation from drifting into a container orchestrator.
02
Scope and promises
Scope and promises
- Register a one-shot job with an absolute fire time, or a recurring job with a cron expression plus an IANA timezone.
- Each job names a target: an HTTPS webhook with a signed body, or a message on a Kafka or SQS topic the caller owns.
- Callers can edit the schedule, pause and resume (resuming skips missed runs unless they ask for catch-up), and delete a job.
- Per-job retry policy: maximum attempts, backoff base and cap, and which HTTP statuses count as retryable.
- A run history per job showing each attempt, its status code, latency, and error text, kept for 30 days.
- Optional overlap policy for recurring jobs: allow, skip the new run, or queue it until the previous one reports done.
- Fire skew, meaning delivery time minus due time, of p99 under 2 seconds in normal operation.
- No accepted job is ever silently dropped. Late is acceptable during an outage, lost is not.
- Duplicate deliveries below 1 in 100,000 fires, each carrying the same run ID so targets can discard it.
- Loss of any single host or a whole availability zone does not stop scheduling.
- The registration API answers in under 150 ms and stays usable even when dispatch is degraded.
How precise does "on time" need to be: a second, or a minute? Do recurring jobs fire on wall-clock time across daylight saving changes, and what happens to a 02:30 job on the night that hour does not exist? Can one tenant create a million jobs due at the same instant? The answers drive bucket width, the timezone library, and per-tenant quotas.
03
Back-of-envelope numbers
Back-of-envelope numbers
Assume 4 million active jobs owned by a few thousand internal services. About 60% are recurring, mostly hourly or daily, and the rest are one-shot reminders and delayed actions. Assume each trigger payload averages 1.5 KB and each history row about 400 bytes.
| Quantity | Working | Result |
|---|---|---|
| Active jobs | assumption | 4M |
| Fires per day | 2.4M recurring at about 9 runs per day, plus 1.6M one-shots | about 23M |
| Average fire rate | 23M / 86,400 s | about 270 / s |
| Top-of-hour spike | about 30% of hourly jobs land on minute 0, second 0 | about 30,000 in one second |
| Midnight UTC spike | daily jobs plus hourly jobs on the same second | about 180,000 due at once |
| Job definition storage | 4M x about 2 KB including indexes | about 8 GB |
| History rows per day | 23M fires x 1.15 attempts | about 26M rows, 10.5 GB |
| History kept 30 days | 10.5 GB x 30 | about 315 GB |
| Dispatch egress at spike | 30,000 / s x 1.5 KB | about 45 MB / s |
| Concurrent in-flight webhooks | 30,000 / s x 3 s median target latency | about 90,000 open requests |
The job table is tiny: 8 GB fits in memory on one database host. Average load is trivial. The whole design is shaped by the spike, a hundred-fold burst that lasts a second or two, and by the long tail of slow targets that hold dispatcher connections open.
Offer callers a jitter window, for example "any time in the first 90 seconds of the hour". Most cache warmers and report jobs do not care about the exact second, and spreading them across 90 seconds cuts the peak by more than an order of magnitude.
04
API surface
API surface
Two resources carry the model. A job is the long-lived definition. A run is one scheduled occurrence of it, with its own attempts. Every mutating call takes an Idempotency-Key so a client retry never creates a second job.
The run ID is deterministic: job ID plus the nominal fire time. Every delivery of that run, including retries and accidental duplicates, carries it in an X-Run-Id header. A target that stores the last run ID it processed gets dedup for one database write.
05
How the pieces fit
How the pieces fit
flowchart LR
C["Caller service"] --> API["Jobs API"]
API --> DB[("Jobs DB, Postgres")]
API --> IDX[("Due index, minute buckets")]
CO["Coordinator, etcd leases"] -. "assigns shards" .-> TK["Tickers, one per shard set"]
TK --> IDX
TK --> DB
TK --> RQ[["Ready queue, Kafka"]]
RQ --> DP["Dispatcher pool"]
DP --> T["Target webhook or topic"]
DP --> RT[["Retry topic with delay tiers"]]
RT --> DP
DP --> H[("Run history, Cassandra")]
DP -. "next fire time" .-> DBThink of the system as three loops that run at different speeds. The registration loop is ordinary CRUD: the API validates a schedule, computes the next fire time in the job's timezone, writes the job row, and inserts one entry into the due index. Nothing on this path waits for the scheduler.
The ticking loop is where timing lives. Jobs are hashed into 1,024 logical shards. A coordinator backed by etcd hands out shards to ticker processes as leases, so each shard has exactly one owner at a time and a crashed ticker's shards move to a survivor within a few seconds. Every second, a ticker reads the entries in its shards that fall due in the next few seconds, claims them with a conditional state change, and publishes a small trigger record to Kafka. Because tickers only read and publish, one process can comfortably run a hundred shards.
The delivery loop is the dispatcher pool. Dispatchers consume the ready topic, make the HTTP call or publish to the target topic, write the attempt to history, and either mark the run finished or schedule a retry. For recurring jobs, the dispatcher that finishes a run, or the ticker that claims it, computes the following occurrence and inserts a fresh index entry. Dispatchers are the only part that scales with target slowness, so they autoscale on in-flight request count, not on CPU.
Postgres holds definitions, owner metadata and the current next_fire_at. At 8 GB it is a single primary with a synchronous standby. It is the source of truth if the due index is ever lost.
A table keyed by (shard, minute_bucket, fire_at, job_id). Tickers scan one bucket per shard, never the whole job set. It can live in the same Postgres or in a partitioned KV store once write volume grows.
Kafka decouples "it is time" from "we delivered it". A spike of 180,000 triggers becomes a queue that dispatchers drain at their own pace, with partitions keyed by job ID so retries of one job stay ordered.
Append-only, write-heavy and queried only by job ID and time, so a wide-column store with a 30-day TTL fits. Keeping it out of Postgres stops 26M daily rows from bloating the job table's vacuum cycle.
A single elected leader is simpler to explain and fine to a few thousand fires per second. Shard leases give you parallel scanning and a small blast radius: when a ticker pauses for a long garbage collection, only its shards fire late, and the coordinator can move them elsewhere.
06
Deep dive: firing once, on time, through crashes
Deep dive: firing once, on time, through crashes
The scheduler has to find due work quickly without scanning millions of rows, and it has to hand that work to exactly one dispatcher even when processes stall, restart or lose their network for a few seconds. These are separate mechanisms and you should present them separately.
Finding due work: minute buckets
A plain WHERE next_fire_at <= now() query on the job table works until the table is busy with writes and the planner starts picking bad paths at the top of the hour. Instead, the due index is bucketed by shard and by minute. A ticker that owns shard 173 reads bucket (173, 2026-10-01T09:00) about ten seconds before that minute starts, loads the entries into a small in-memory timing wheel, and releases each one when its second arrives. One indexed range read per shard per minute replaces thousands of polling queries.
Late writes still work. A job created at 09:00:20 that is due at 09:00:40 lands in the current bucket after the ticker has read it, so the ticker also re-reads the current bucket every few seconds, picking up only rows with an inserted_at later than its last read. For one-shot jobs due less than five seconds out, the API can skip the index and publish straight to the ready topic.
flowchart TD
A["Ticker owns shard with lease epoch 42"] --> B["Read minute bucket into timing wheel"]
B --> C{"Entry due this second?"}
C -->|no| B
C -->|yes| D["UPDATE index SET state=claimed, epoch=42 WHERE state=pending AND shard epoch still 42"]
D -->|0 rows| X["Skip: another owner or already claimed"]
D -->|1 row| E["Publish trigger with run_id to ready topic"]
E --> F["Dispatcher calls target with X-Run-Id"]
F --> G{"2xx?"}
G -->|yes| H["Record success, insert next occurrence"]
G -->|no| I["Publish to retry tier with backoff"]One owner per run: leases plus fencing
Shard leases come from etcd with a 10-second TTL that the ticker renews every 3 seconds. Each time a shard changes hands, its epoch number goes up. The ticker attaches its epoch to every claim, and the claim is a conditional update in the database that succeeds only if the shard's current epoch still matches. This matters because a lease is only a belief. A ticker that froze for 15 seconds still thinks it owns shard 173 when it wakes up, but its epoch is now stale and every claim it attempts updates zero rows. The database, not the ticker's clock, decides.
The claim itself is idempotent: an index entry moves from pending to claimed once. If the ticker crashes between claiming and publishing to Kafka, the entry sits in claimed with no trigger anywhere. A sweeper running on the new owner looks for entries claimed more than 30 seconds ago with no matching run record and republishes them. Using a transactional outbox, where the claim and the outgoing trigger row commit together and a relay ships the row to Kafka, closes that gap completely at the cost of one more table.
Delivery: at least once, dedup at the edge
Dispatchers commit their Kafka offset only after writing the attempt outcome. If one dies mid-request, another dispatcher receives the same trigger and calls the target again. That is the residual duplicate, and it is unavoidable without the target's help. We shrink it in three ways: the run ID header lets targets drop repeats, a short-lived Redis key SET run_id NX EX 3600 stops two of our own dispatchers from both sending, and webhook timeouts are kept well below the Kafka session timeout so a slow call does not trigger a rebalance and a second delivery.
Retries go to delay tiers rather than sleeping in a dispatcher. There are topics for 5 seconds, 1 minute, 10 minutes and 1 hour. A failed attempt is published to the smallest tier that covers its backoff, and a consumer on that tier holds messages until they are due. After the last attempt, the run is marked dead and the owning team gets an alert, never a silent drop.
Timezones and daylight saving
Store the cron expression and the timezone, never a pre-computed UTC offset. Compute each next occurrence with a proper tz database at the moment you insert it. On the spring-forward night, a 02:30 job in a zone that skips from 02:00 to 03:00 has no valid local time, so pick a documented rule, usually "fire at 03:00". On the fall-back night, 01:30 happens twice; fire on the first occurrence only. Interviewers like this detail because it is where naive designs double-fire or skip a day.
07
Choices and their price
Choices and their price
Scanning is parallel, a stalled ticker only delays its own shards, and stale owners are rejected by the database. You run a coordinator and must reason about epochs, which is more machinery than a single leader.
Scales and contains faultsEasy to build with a Postgres advisory lock or a ZooKeeper election. It works well below about 3,000 fires a second. Above that, one process becomes the bottleneck at the top of the hour, and every failover pauses all scheduling at once.
Good first versionOne range read per shard per minute, with second-level precision from the wheel. The cost is a little memory per ticker and a re-read loop for jobs inserted into the current minute.
Cheap and preciseFine at small scale, but it turns the source-of-truth database into a hot queue and competes with API writes exactly when load peaks. Redis sorted sets keyed by fire time are a reasonable middle ground if you accept a second store to keep in sync.
Breaks at the spikeAbsorbs the spike, lets dispatchers scale independently, and gives a replayable log of every trigger. You accept a few milliseconds of extra latency and another system to operate.
Buffer the burstLowest latency and fewer hops. But one slow target ties up ticker threads, and timing precision collapses for every other job on that shard. Only sensible when targets are fast and trusted.
Couples timing to deliveryHonest about what distributed delivery can guarantee and cheap for targets to handle. Requires every team to read the header, which you enforce with documentation and a client library.
Correct and simpleMark the run done before calling the target and never retry. No duplicates, but a crash loses the trigger, which breaks the "never silently dropped" promise for billing and reminders.
Loses work08
Things that break in production
Things that break in production
The process stops for 20 seconds. Its etcd lease expires, the coordinator gives its 90 shards to another ticker, and the new owner starts firing. Then the old process wakes up still holding its in-memory wheel and tries to claim the same entries.
Every claim carries the shard epoch and the database rejects stale epochs, so the old ticker's claims update nothing. It notices the rejected claims, drops its wheel and asks the coordinator for fresh shards.A team schedules 400,000 per-user digest jobs at 00:00 all pointing at the same endpoint. The endpoint falls over, every call fails, and the retry tiers fill with a second and third wave that hits the recovering service at exactly the worst moment.
Enforce a per-target concurrency limit in dispatchers, add jitter to retries, open a circuit breaker per target after a failure rate threshold, and push large fan-outs toward jitter windows at registration time.One integration starts taking 9 seconds per call. Dispatcher connections fill with waiting requests, and fast jobs on other tenants start firing a minute late even though their own targets are healthy.
Bulkhead dispatchers by tenant or target class, cap timeouts at 10 seconds, and autoscale on in-flight count. Measure fire skew per tenant so the noisy one is visible.NTP breaks on one host and its clock runs 40 seconds fast. Its shards fire early, which is worse than late for anything like "release this embargoed article at 10:00".
Alert on NTP offset, have tickers refuse to fire if their measured offset exceeds one second, and let dispatchers reject triggers that arrive more than a second before their nominal time.A bad migration truncates part of the index. Jobs still exist in the job table, but nothing will ever pick them up, and nobody notices until a customer asks why renewals stopped.
Run a reconciler hourly that compares each active job'snext_fire_at with the index and reinserts anything missing. Alert on "jobs whose next fire time is in the past and not claimed".
09
What interviewers listen for
What interviewers listen for
Start from cron on one box. Say what breaks: availability, missed runs during downtime, and the burst at round times. Each later component then answers one of those, and the design sounds reasoned rather than recited.
Lead with the spike, not the average. Show that 270 fires a second on average becomes tens of thousands in the first second of an hour. That justifies buffering and jitter.
Refuse to promise exactly-once. Explain the crash-after-call ambiguity in one sentence, then describe at-least-once with a stable run ID. This is the single most common probe in this question.
Explain why a lease alone is not enough. A paused process can hold a lease it no longer owns. Fencing with an epoch checked by the storage layer is the answer they are waiting for.
Separate deciding from delivering. Tickers find due work, dispatchers call targets. Draw the queue between them and say what it protects: timing precision from slow targets.
Mention timezones and daylight saving before they do. Store the zone, compute occurrences lazily, and have a written rule for skipped and repeated local hours.
Talk about noisy tenants. Per-target concurrency limits, circuit breakers and quotas on jobs due at the same second show you have run a shared platform.
10
From one cron host to a sharded fleet
From one cron host to a sharded fleet
Each step below fixes something that measurably broke in the previous one. Do not draw stage four for a team with ten thousand jobs.
One Postgres table with a next_fire_at index, one process that wakes every second, and a thread pool that calls targets. It is easy to reason about and good enough for most products.
A second scheduler waits on an advisory lock for failover. Calling targets moves to a worker pool that claims runs with SELECT ... FOR UPDATE SKIP LOCKED, so slow targets no longer delay the ticker.
The top-of-hour burst overwhelms the worker pool's database polling. Put Kafka between scheduling and delivery, add delay tiers for retries, and move run history to its own store.
The single scheduler cannot scan fast enough at midnight. Split into 1,024 shards leased through etcd, bucket the due index by minute, and fence every claim with the shard epoch. The single leader is gone.
Each job gets a home region where it is scheduled, with its definition replicated elsewhere for failover. Regional failover moves shard leases, not individual jobs, so recovery is a coordinator operation rather than a data migration.
Watch and read
Further watching and reading
Try next
Free to read · better with landed
Whiteboard this with landed
landed runs it as a live system design round on the whiteboard and grades your trade-offs.