Skip to main content
PProfileMatch

MediumProblem 16 of 36 in the system design series

Design a Job Scheduler: Running Billions of Timed Tasks Reliably

Cover illustration: Design a Job Scheduler: Running Billions of Timed Tasks Reliably

A distributed job scheduler accepts work that should happen later, such as sending a reminder email tomorrow at nine, generating a nightly report, or charging a subscription every month, and makes sure that work runs when it is due. Callers hand over a task name, a payload and a time or cron expression, then rely on the platform to execute it and record what happened.

Interviewers ask this because it blends storage design, coordination and failure handling. A single cron daemon on one box is easy to describe, but it stops being acceptable once you need billions of runs a day, no lost jobs and no single machine whose death halts everything. The candidate has to explain how due work is found cheaply, how it is handed out safely, and what happens when workers die halfway through.

The difficulty comes from timing and duplication. Load is spiky because people love round numbers like midnight, scanning a giant table every second is ruinous, and in a world of crashing machines guaranteeing exactly one execution is not realistic. The design embraces at-least-once delivery and builds idempotency around it.

This is an interview practice design for a Job Scheduler-like service, built from first principles. It is not a description of how Job Scheduler is actually built.

Functional Requirements

Users must be able to register a job that fires once at a specific timestamp or repeatedly according to a cron expression. Supporting both covers the common patterns, from a single delayed notification to a recurring billing run. When the time comes, the platform invokes the named task with the stored payload, so the scheduler stays generic and the business logic lives in task handlers.

Jobs also need management: cancelling, pausing or editing a job before its next run prevents stale work from firing. Finally, every run should be visible with a status such as queued, running, succeeded or failed, because operators debugging a missed invoice need a history to inspect. Dependency graphs between jobs, the kind of DAG workflows Airflow handles, are a separate orchestration problem and stay out of scope.

Schedule a job

Run a task once at a given time, or repeatedly on a cron schedule.

one-offcron

Execute when due

At the due time, the job's task runs with its payload.

task + payload

Manage jobs

Cancel, pause or update a job that is not running yet.

cancel · pause

Status & history

See every run: queued, running, succeeded or failed.

execution log

  • Out of scope today: job dependencies and DAG workflows, like Airflow

Non-Functional Requirements

Timeliness is the first quality target: a job should begin within about two seconds of its due moment. Durability is just as important. Once the API acknowledges a job, a crash anywhere in the system must not make it disappear, so we commit to at-least-once execution.

Scalability matters because thousands of jobs can become due in the same second, with sharp spikes at the top of each hour. That pushes toward horizontally scalable storage and workers rather than one powerful machine. High availability rounds things out. A single scheduler process whose failure freezes every customer's jobs is the exact weakness we are designing away, so scheduling itself has to be spread across several nodes that can take over from each other.

On time

A job starts within a couple of seconds of its due time.

< 2 s delay

Never lose a job

Once accepted, a job runs, even if servers crash.

durableat-least-once

Scalable

Thousands of jobs due every second, with big spikes.

horizontal scale

Highly available

No single scheduler box that can stop everything.

no single point

Scale Estimation

Assume roughly 1 billion job runs per day, a blend of one-off and recurring jobs. That averages around 12,000 runs per second, but averages mislead here: recurring jobs cluster at round times, so assume peaks above 100,000 per second at the top of the hour or at midnight.

Execution history adds up as well. At about 200 bytes per run record, that is roughly 200 GB a day, or around 6 TB if we keep 30 days. The combination of a huge table and bursty access rules out the naive approach of querying all jobs for anything past due. We need an access path that touches only the slice of data relevant to the next few moments.

1billion

Job runs per day

one-off + cron

12K avg

Runs per second

≈ 100K+ at :00 peaks

200GB / day

Run log (200 B / run)

≈ 6 TB for 30 days

Bursty at round times → never scan the whole table to find due jobs

Core Entities & API

Three entities shape the model. A job defines what to execute: the task and its payload. A schedule defines when, either a single run_at or a cron expression. An execution represents one concrete run with its own status and attempt count. Splitting execution from job is what lets a recurring job keep a clean history and lets retries appear as separate records.

Creating work uses POST /jobs with the task, payload, either run_at or cron, and an optional retry policy; the response is a job ID. GET /jobs/{id}/executions lists runs with status, scheduled time and attempt number, which is the audit trail operators need. DELETE /jobs/{id} cancels a job and returns 204. Pause and update can follow the same pattern as additional endpoints.

Core entities

JobScheduleExecution

API

  • POST /jobs { task, payload, run_at? | cron?, retry? }→ { job_id }
  • GET /jobs/{id}/executions→ [ { status, run_at, attempt } ]
  • DELETE /jobs/{id}→ 204 cancelled

High-Level Design

Callers, usually other backend services, talk to a job API that validates requests and writes the job plus its next execution into a jobs database. That execution is indexed by due time, which is the property everything else depends on.

A scheduler service continuously looks ahead at executions due in the next short window, never the entire table. When an execution's moment arrives, the scheduler places it on a queue such as Kafka or SQS. The queue acts as a shock absorber: at midnight, the scheduler can enqueue a flood of work quickly while the worker pool drains it at its own pace.

Workers pull messages, call the right handler for each task and report the outcome back to the database. For recurring jobs, the worker also inserts the following execution. Several scheduler instances run in parallel, and a coordinator such as etcd assigns each one a distinct set of shards. Ownership is exclusive, so no due job is enqueued twice by competing schedulers, and if one instance dies its shards are reassigned.

save jobnext time windowdue nowstatus · next runown shardsClientservices · usersJob APIcreate · cancelJobs DBjobs + executionsSchedulerfinds due jobsQueueKafka · SQSWorkersrun the taskCoordinatoretcd · shard owners
High-level design. Dashed arrows are taken only on a cache miss or as a fallback. Scroll sideways on small screens.

Deep Dive 1 — Finding Due Jobs

Polling the jobs table every second for overdue rows is the obvious first answer, and it collapses once the table reaches billions of rows because each poll becomes an expensive scan. A Redis sorted set scored by run time gives fast range reads, yet the whole schedule must fit in memory and durability is weaker than a real database, which conflicts with the promise never to lose a job.

The preferred design is a time-bucketed execution table. The partition key is the minute a job is due, combined with a shard number so a crowded minute is spread across several partitions; rows within a partition sort by run time and execution ID. Fetching what is due means reading one small partition. Wide-column stores like Cassandra or DynamoDB suit this layout naturally.

Each scheduler loads its shards of the upcoming minute slightly ahead of time, holds those executions in memory and enqueues each at its precise second. A job created only 30 seconds before it is due could slip past that preload, so the API sends anything due within the next minute directly to the queue with a delivery delay.

Scan jobs table

  • + Very simple
  • − Full scan every second
  • − Slow at billions of rows

Redis sorted set

  • + Fast range by time
  • − Memory-bound
  • − Weaker durability

Time-bucketed indexChosen

  • + Reads one small bucket
  • + Durable and shardable
  • − Bucket design needed
  • Key = (minute bucket, shard) · sort by run_at + execution_id
  • Pre-load the next minute → fire each job at its exact second
  • Due in under a minute? The API pushes it to the queue with a delay

Deep Dive 2 — At-Least-Once Execution

Workers fail, so delivery must survive them. When a worker receives a message, the queue does not delete it; it hides it for a visibility timeout of perhaps 30 seconds. The worker then claims the execution by switching its status from QUEUED to RUNNING with a compare-and-set, so if two workers somehow hold the same message, only one succeeds in claiming it.

Jobs that take longer than the timeout send periodic heartbeats that extend both the lease and the message visibility. If a worker crashes, its heartbeats stop, the lease runs out and the message reappears for someone else to process. On success, the worker records SUCCEEDED first and only afterward acknowledges the message, so a crash between the two leads to a repeat rather than a loss.

That ordering means a task can occasionally execute twice, for example when a slow worker finishes just after its lease expired. Tasks therefore treat the execution ID as an idempotency key: a payment handler, for instance, records the execution ID with the charge and skips it if it has already been applied.

  1. 1Pull from queueMessage hidden for a visibility timeout, say 30 s
  2. 2Claim with a leaseSet status QUEUED → RUNNING with a compare-and-set
  3. 3HeartbeatLong jobs keep extending their lease
  4. 4Worker crashesLease expires → message reappears → another worker
  5. 5Ack on successMark SUCCEEDED, then delete the message
  • Idempotency key = execution_id → a second run changes nothing

Deep Dive 3 — Retries & Backoff

Not every failure deserves another attempt. Timeouts and 503 responses usually reflect temporary trouble and are worth retrying, while a malformed payload will fail forever and should be marked failed at once. Retries use exponential backoff, roughly 1, 2, 4 and 8 seconds, plus random jitter so that a batch of jobs that failed together does not hammer the dependency again in lockstep.

A neat simplification is that a retry is just another execution placed at the current time plus the backoff in the same time-bucketed index. No separate retry subsystem is needed. After a cap, say five attempts, the job moves to a dead-letter queue and its owner is alerted to investigate. Per-tenant concurrency limits complete the picture, preventing one customer's runaway job from overwhelming a shared downstream service with thousands of simultaneous calls.

  • Retryable or permanent?

    Timeouts and 503s are retried; a bad payload fails straight away.

  • Exponential backoff with jitter

    Wait 1 s, 2 s, 4 s, 8 s … plus a random jitter so retries don't all land together.

  • A retry is just a new execution

    Insert it at now + backoff in the same time-bucketed index.

  • Max attempts, then dead-letter

    After, say, 5 tries: move to a DLQ and alert the job owner.

  • Protect downstream services

    Per-tenant concurrency limits, so one noisy job can't flood a service.

Trade-offs

Exactly-once execution across machines is not practically achievable, so the design pairs at-least-once delivery with idempotent tasks, which is the accepted industry answer. Bucket size trades precision for load: smaller buckets give tighter timing but mean more frequent queries against the database.

Placing a queue between schedulers and workers absorbs spikes and decouples the two tiers, at the price of another system to deploy and monitor. Storage choice is a familiar tension too: a SQL database makes transactional status updates easy, while a wide-column store scales write-heavy execution data with less effort. The overall formula is to index work by time, protect every run with a lease, and design each task to be safe to run again.

  • At-least-once vs exactly-once

    True exactly-once is impractical; at-least-once plus idempotency is the standard.

  • Small vs large time buckets

    Small buckets mean tighter timing, but more queries to the database.

  • Queue vs workers polling the DB

    A queue absorbs spikes and decouples us — but it's one more system to run.

  • SQL or wide-column store

    SQL is easy for transactions; Cassandra or DynamoDB scale writes more easily.

Index by time. Lease every run. Make every task idempotent.

Key takeaways

  • Assume 1 billion runs a day with midnight peaks over 100K per second; never scan the full table.
  • Partition executions by due minute and shard, then read only the bucket that is about to fire.
  • A queue between schedulers and workers absorbs spikes at round times.
  • Leases, heartbeats and visibility timeouts give at-least-once execution through worker crashes.
  • Make tasks idempotent with the execution ID, and retry with exponential backoff, jitter and a DLQ.

Interview questions

How does a distributed job scheduler find due jobs efficiently?

It stores executions in a table partitioned by the minute they are due, often split further into shards. To find work, a scheduler reads only the current or upcoming bucket instead of scanning every row. It can preload the next minute into memory and enqueue each job at its exact second, while jobs due very soon are pushed straight to a delayed queue.

What happens when a scheduler worker dies mid-run?

The queue only hides the message for a visibility timeout and the worker holds a lease that it extends with heartbeats. When the worker crashes, heartbeats stop, the lease expires and the message becomes available again, so another worker processes the job. Because the completion is recorded before the message is acknowledged, a crash leads to a repeat rather than a lost job.

Can a scheduled job run twice, and how do you handle it?

Yes, occasionally. With at-least-once delivery, a slow worker may finish after its lease has expired while another worker has already started the same run. The fix is to make tasks idempotent by using the execution ID as an idempotency key. The task checks whether that ID was already applied and skips the side effect if so.

How should failed jobs be retried in a job scheduler?

First classify the failure: retry transient errors such as timeouts and 503s, but fail invalid payloads immediately. Use exponential backoff with random jitter to space attempts out. Each retry is simply a new execution scheduled at now plus the delay. Once attempts run out, park it in a dead-letter queue and notify the owning team.

How do you avoid a single point of failure in a cron scheduler?

Run several scheduler instances and divide the work into shards. A coordination service such as etcd assigns each shard to exactly one scheduler and reassigns it if that instance fails. Durable storage holds every execution, and a queue plus a pool of stateless workers performs the runs, so losing any single machine only pauses a slice of work briefly.

Keep practising