Design a Distributed Job Scheduler
A service that runs millions of one-off and recurring (cron) jobs on time, distributing work across a fleet of workers with retries, priorities, and at-least-once (approaching exactly-once) execution guarantees, even when schedulers and workers crash mid-flight.
1Requirements
Functional
- • Schedule one-off jobs to run at a specific future time (run_at)
- • Schedule recurring jobs with cron expressions, computing the next run after each firing
- • Retry failed jobs with exponential backoff up to a configurable max attempts
- • Support job priorities so urgent jobs jump the queue under load
- • Let clients query job status (pending, running, succeeded, failed, dead) and cancel pending jobs
- • Prevent the same job execution from running concurrently on two workers
Non-functional
- • Timeliness: 99 percent of jobs start within 10 seconds of their scheduled time
- • At-least-once execution with idempotency hooks so effective exactly-once is achievable for well-behaved jobs
- • Horizontal scalability: 10k+ job executions per second across the fleet
- • No single point of failure: a crashed scheduler or worker never loses or strands a job
- • Durability: an accepted job survives node failures until it completes or exhausts retries
2Back-of-envelope estimation
| Jobs scheduled per day | 500 million | Mix of one-off and cron firings |
| Average execution QPS | ~5,800 | 500M / 86,400 s; provision for 3x peak at top-of-minute cron alignment, ~17k QPS |
| Job row size | ~1 KB | Payload pointer, schedule, status, timestamps, attempt count |
| Hot table size | ~500 GB/day before archival | 500M x 1 KB. Move terminal-state rows to cold storage within hours to keep the polled index small |
| Worker fleet | ~1,200 workers | 5,800 QPS x 10 s average job duration = 58k concurrent jobs; at 50 concurrent slots per worker |
| Poll load on DB | ~120 polls/s | 1,200 workers polling every 10 s. Fine for one Postgres primary; shard by queue when this grows 100x |
3API design
POST /v1/jobsCreate a job: { type, payload, run_at | cron, priority, max_attempts, idempotency_key }. Returns job_id. Idempotency key dedupes client retries of the create call itself.
GET /v1/jobs/{id}Fetch job status, attempt history, last error, and next scheduled run for cron jobs.
DELETE /v1/jobs/{id}Cancel a pending job or stop future firings of a cron job. Running executions finish; they are not killed.
POST /internal/leases/claimWorker API: atomically claim up to N due jobs, receiving a lease token and lease expiry per job.
POST /internal/leases/{token}/heartbeatWorker API: extend the lease for a long-running job, or report completion/failure. A completion with a stale token is rejected.
4High-level design
Three planes: an API service that validates and persists jobs, a scheduling plane that decides what is due, and a worker plane that executes. The single most important design decision is that the source of truth is a durable jobs store (a relational DB works well into the tens of thousands of QPS), and everything else (queues, in-memory timing wheels) is a rebuildable acceleration layer on top of it.
The scheduling plane handles two shapes of time. One-off jobs are simply rows with run_at; a due-job query is an index scan on (status, run_at). Cron jobs are templates: when a firing is claimed, the scheduler materializes the execution and immediately computes and writes the next run_at from the cron expression, so a cron job is just a self-replenishing one-off. This unifies the model and means missed windows (scheduler down for 5 minutes) are naturally caught up because due rows are still due.
Dispatch uses worker leases rather than fire-and-forget queues. A worker claims due jobs with an atomic UPDATE ... RETURNING that flips status to running, stamps the worker id, and sets lease_expires_at = now + lease_duration. If the worker dies, it stops heartbeating, the lease expires, and a reaper flips the row back to pending for another worker. This gives at-least-once execution with no lost jobs and no zookeeper-style external lock service.
Exactly-once semantics are layered on with fencing and idempotency. Every claim gets a monotonically increasing attempt number that acts as a fencing token: a zombie worker that wakes up after its lease expired will have its completion report rejected because its token is stale. For side effects outside the system (send an email, charge a card), the job payload carries an idempotency key that the downstream effect must honor; the scheduler can only guarantee exactly-once state transitions, not exactly-once side effects.
Priority and load shedding: the claim query orders by (priority DESC, run_at ASC), so high-priority jobs are always claimed first, and under sustained overload low-priority jobs simply age in the queue rather than causing failures. Retries reinsert the job with run_at = now + backoff(attempt), where backoff is exponential with jitter, and after max_attempts the job moves to a dead-letter state for human inspection.
5Data model
jobs
job_id (PK), type, payload_json, priority, status (pending|running|succeeded|failed|dead), run_at, cron_expr, attempt, max_attempts, lease_expires_at, worker_id, idempotency_key (unique), created_at, updated_atPartial index on (priority DESC, run_at) WHERE status = 'pending' keeps the claim scan tiny regardless of table size
job_attempts
attempt_id (PK), job_id (FK), attempt_no, worker_id, started_at, finished_at, outcome, error_textAppend-only audit trail; also what the status API reads for history
workers
worker_id (PK), hostname, capacity, last_heartbeat_atReaper marks a worker dead when heartbeat is stale and expires all its leases in one sweep
6Deep dives
Cron at scale and the thundering herd at :00
Cron expressions cluster brutally: an enormous fraction of jobs are scheduled at the top of the hour or minute because humans write '0 * * * *'. If 2 million jobs become due in the same second, a naive scheduler melts. Three mitigations stack well. First, jitter at registration: unless the job opts into strict timing, hash the job id into a 0-59 second offset so '0 * * * *' spreads across the minute. Second, the due-query itself is naturally rate-limited because workers claim in fixed-size batches; dueness is a floor, not a trigger, so a backlog drains at fleet capacity instead of stampeding. Third, pre-materialize: a sweeper runs every 30 seconds and expands cron templates into concrete execution rows for the next few minutes, so firing time does cheap row claims rather than cron parsing.
Recurrence bookkeeping has one classic bug: computing next_run from the previous scheduled time versus from completion time. Fixed-rate (from scheduled time) keeps cadence but can pile up overlapping runs if executions are slow; fixed-delay (from completion) avoids overlap but drifts. Offer both, default to fixed-rate with an overlap guard: skip materializing a new execution while one is still running, incrementing a missed_runs counter instead.
Also decide catch-up policy explicitly: after a 30-minute outage, does an every-5-minutes job fire 6 times or once? Almost every consumer wants once (coalescing). Make coalescing the default and let strict jobs opt out.
Worker leases, fencing tokens, and the exactly-once illusion
The claim operation must be atomic or two workers will run the same job. In SQL this is one statement: UPDATE jobs SET status='running', worker_id=$w, attempt=attempt+1, lease_expires_at=now()+interval '60 seconds' WHERE job_id IN (SELECT job_id FROM jobs WHERE status='pending' AND run_at <= now() ORDER BY priority DESC, run_at LIMIT 10 FOR UPDATE SKIP LOCKED) RETURNING *. The FOR UPDATE SKIP LOCKED clause is the whole trick: concurrent claimers skip rows another transaction has locked instead of blocking, so N workers claim disjoint batches with zero coordination.
Leases handle worker death, but they create the zombie problem: a worker stalls (GC pause, network partition), its lease expires, the job is re-claimed by worker B, then worker A wakes up and finishes too. The job ran twice (unavoidable under at-least-once) but worse, A's completion could overwrite B's state. The fix is fencing: the attempt number captured at claim time is A's token; completion is UPDATE jobs SET status='succeeded' WHERE job_id=$id AND attempt=$myAttempt AND worker_id=$me. A's stale attempt number makes the update match zero rows, and A learns it was fenced.
True exactly-once side effects are impossible in general (the worker can crash between the side effect and the ack), so the honest contract is: exactly-once state transitions inside the scheduler, at-least-once invocation of the job body, and idempotency keys handed to the job so it can make its own side effects safe. Say exactly this in the interview; claiming unconditional exactly-once is a red flag.
Retries, backoff, and dead letters
A failed attempt should not retry immediately: if the failure is a downstream outage, instant retries are a self-inflicted DDoS. Standard policy is exponential backoff with full jitter: delay = random(0, min(cap, base * 2^attempt)), for example base 5 s, cap 15 minutes. Full jitter (randomizing over the whole window rather than adding small noise) is what actually breaks retry synchronization when thousands of jobs failed at the same moment.
Distinguish failure classes. A retryable failure (timeout, 503) reschedules with backoff. A permanent failure (validation error, 4xx from downstream) should skip straight to dead: retrying a job that can never succeed wastes capacity and delays real work. Let the job body signal which class it hit; default unknown errors to retryable.
After max_attempts, park the job in a dead-letter state with its full attempt history. Dead letters need an operational story: alerting when the dead rate spikes, a UI to inspect payload and errors, and a bulk requeue action for after the downstream incident is fixed. A scheduler without a dead-letter workflow silently loses work, just with extra steps.
Scaling past one database
A single Postgres with the partial-index claim pattern comfortably handles a few thousand claims per second, which covers a surprising fraction of real companies. When you outgrow it, shard by queue/tenant: each shard is an independent jobs table with its own workers, and a thin router assigns jobs to shards by hash of tenant id. Cross-shard priority is approximated, not global, which is almost always acceptable.
The alternative architecture replaces DB polling with a delay-queue substrate: Redis sorted sets keyed by run_at (ZADD to schedule, ZRANGEBYSCORE plus atomic ZREM in a Lua script to claim), or Kafka with a timing-wheel service in front since Kafka has no native delay. These cut claim latency to single-digit milliseconds but reintroduce the durability question: Redis needs AOF plus replication, and you typically still write-through to a durable store for status queries and audit, which is exactly the two-tier design (durable truth plus fast acceleration layer) restated.
Scheduler-plane HA is simpler than it looks: the sweeper and reaper are the only singleton-ish components, and they can run on every node guarded by a short DB advisory lock, so failover is just the next node grabbing the lock.
7Rapid implementation: build the MVP
Theory is table stakes. Here is how you would stand up a working version fast, on a budget, with the core algorithm in real code.
Stack
Postgres (Supabase free tier) as the queue, Node.js worker processes with node-cron for the sweeper, no message broker at all
- 01Create the jobs table with the partial index: CREATE INDEX ON jobs (priority DESC, run_at) WHERE status = 'pending'
- 02Write a claimJobs(workerId, n) function using UPDATE ... FROM a SKIP LOCKED subquery, RETURNING the claimed rows
- 03Write the worker loop: claim up to 10 jobs, execute each with a try/catch, report success or failure with the fencing predicate (AND attempt = claimedAttempt)
- 04Add a heartbeat interval per running job that pushes lease_expires_at forward every 20 seconds
- 05Add the reaper: every 30 seconds, UPDATE jobs SET status='pending' WHERE status='running' AND lease_expires_at < now()
- 06Add cron support with the cron-parser npm package: on claiming a cron job, insert the next execution row before running the current one
- 07Implement failure handling: retryable errors set run_at = now + jittered backoff and status='pending'; attempt >= max_attempts sets status='dead'
- 08Kill -9 a worker mid-job and watch the reaper recover it; run 3 workers against 10k seeded jobs to verify no job runs with two overlapping leases
Atomic batch claim with SKIP LOCKED
sql-- Claim up to 10 due jobs atomically; concurrent workers get disjoint sets.
UPDATE jobs j
SET status = 'running',
worker_id = $1,
attempt = j.attempt + 1,
lease_expires_at = now() + interval '60 seconds'
FROM (
SELECT job_id
FROM jobs
WHERE status = 'pending'
AND run_at <= now()
ORDER BY priority DESC, run_at ASC
LIMIT 10
FOR UPDATE SKIP LOCKED
) due
WHERE j.job_id = due.job_id
RETURNING j.job_id, j.type, j.payload_json, j.attempt, j.cron_expr;Worker loop with fenced completion
typescriptimport { sql } from "./db";
import { handlers } from "./handlers";
const WORKER_ID = process.env.WORKER_ID ?? "worker-" + process.pid;
async function runOne(job: { job_id: string; type: string; payload_json: any; attempt: number }) {
const hb = setInterval(() => {
sql("UPDATE jobs SET lease_expires_at = now() + interval '60 seconds' " +
"WHERE job_id = $1 AND worker_id = $2 AND attempt = $3",
[job.job_id, WORKER_ID, job.attempt]).catch(() => {});
}, 20_000);
try {
await handlers[job.type](job.payload_json);
// Fencing: attempt must still match, or a zombie is trying to complete.
await sql(
"UPDATE jobs SET status = 'succeeded' " +
"WHERE job_id = $1 AND worker_id = $2 AND attempt = $3",
[job.job_id, WORKER_ID, job.attempt]
);
} catch (err) {
const backoffSec = Math.random() * Math.min(900, 5 * 2 ** job.attempt);
await sql(
"UPDATE jobs SET " +
" status = CASE WHEN attempt >= max_attempts THEN 'dead' ELSE 'pending' END, " +
" run_at = now() + ($4 || ' seconds')::interval " +
"WHERE job_id = $1 AND worker_id = $2 AND attempt = $3",
[job.job_id, WORKER_ID, job.attempt, backoffSec.toFixed(0)]
);
} finally {
clearInterval(hb);
}
}Cron materialization on claim
typescriptimport parser from "cron-parser";
import { sql } from "./db";
// Called right after claiming a job that has a cron expression:
// schedule the next firing before running this one (fixed-rate semantics).
export async function materializeNext(job: {
job_id: string; type: string; payload_json: any; cron_expr: string;
}) {
const next = parser.parseExpression(job.cron_expr).next().toDate();
await sql(
"INSERT INTO jobs (type, payload_json, cron_expr, run_at, status, priority, max_attempts) " +
"SELECT type, payload_json, cron_expr, $2, 'pending', priority, max_attempts " +
"FROM jobs WHERE job_id = $1 " +
"AND NOT EXISTS (" +
" SELECT 1 FROM jobs WHERE cron_expr = $3 AND type = $4 " +
" AND status = 'pending' AND run_at = $2" +
")",
[job.job_id, next.toISOString(), job.cron_expr, job.type]
);
}Bottlenecks & failure modes
- ⚠Top-of-minute cron alignment creates 100x load spikes; jitter registration offsets and pre-materialize executions
- ⚠The pending-jobs index becomes the contention hot spot; FOR UPDATE SKIP LOCKED and a partial index WHERE status='pending' keep claimers from serializing
- ⚠Long-running jobs holding leases block visibility; require heartbeats and size lease_duration to a few heartbeat intervals, not job duration
- ⚠Table bloat from billions of terminal rows slows the claim scan; archive succeeded/dead rows to cold storage aggressively
- ⚠Retry storms after a downstream outage; full-jitter backoff plus a per-job-type circuit breaker that pauses claiming a failing type
Key takeaways
- ▸Durable store as truth, queues as acceleration: you can rebuild dispatch state from the jobs table after any crash
- ▸FOR UPDATE SKIP LOCKED turns a plain relational DB into a competitive work queue with atomic, coordination-free claiming
- ▸Leases plus fencing tokens (attempt number) are the standard answer to worker crashes and zombies
- ▸Promise at-least-once execution with idempotency hooks; exactly-once side effects are a downstream contract, not a scheduler feature
- ▸Design the failure path first: backoff with full jitter, permanent-vs-retryable classification, and a dead-letter workflow