SysDesignPrep.com
System design interview question

Design a Distributed Job Scheduler

Run millions of cron jobs and one-off tasks on time across a fleet, exactly once each, and keep going when a worker dies halfway through one.

Last updated 2026-09-22. Difficulty: medium. Patterns: scheduling, leases, idempotency, queues, fault-tolerance. Reported at Amazon and 5 more with Pro.

Walk through a strong candidate's answer, turn by turn.

The interviewer asks, the candidate answers and draws, and you press Next. Pause to answer yourself at the key decisions, and ask the coach anything along the way.

Functional requirements

  • Schedule recurring jobs. Cron expressions with a time zone, because "09:00 every weekday" means different instants in March depending on where the customer is.
  • Schedule one-off jobs at a future time. Send this reminder in 40 days, expire this hold in 10 minutes. The same machinery, with a schedule of one.
  • Run each due execution once. The hard requirement. Charging a card twice because a worker retried is the failure this system exists to prevent.
  • Retry failures with backoff. A bounded number of attempts with exponential backoff and jitter, then a dead-letter queue and an alert rather than an infinite loop.
  • Respect concurrency and ordering per job. A job that overruns its interval must not start a second copy unless it says it may, and some jobs must not run concurrently with each other.
  • Observability and control. History per job, why the last run failed, and the ability to pause, resume, trigger now and backfill a missed window.
  • Out of scope. The execution environment itself (containers, capacity), workflow orchestration with dependencies between jobs, and data pipeline lineage.

Non-functional requirements

  • Scheduling accuracy (within 1 s of due time at p99). Late is usually fine, early never is. "Run at 09:00" that fires at 08:59 breaks business logic that assumes the day has started.
  • Scale (10 M scheduled jobs, 100 k executions/s peak). The peak is at the top of the minute and especially the top of the hour: a self-inflicted thundering herd.
  • Delivery (at least once, with idempotent execution). Exactly-once execution is not achievable across a network. Say so, then make duplicates harmless.
  • Durability (no scheduled job lost). A job accepted and then forgotten is invisible until someone notices a report never arrived. Silent loss is the worst failure mode here.
  • Availability (99.99 %). A scheduler outage is not a crash, it is a delay, but a delay long enough becomes a missed window, which for some jobs is a lost day.
  • Isolation (no tenant starves another). One customer scheduling a million jobs at midnight must not delay everyone else's.
  • Long jobs (up to 6 hours). The execution model must tolerate a task that outlives any single lease renewal interval.

Back-of-envelope estimates

  • Scheduled jobs: ~10 M. 100 k tenants × ~100 jobs each = 10 M definitions. At ~1 KB each that is 10 GB of definitions: small. The volume is in executions, not definitions.
  • Executions per day: ~1 B. 10 M jobs averaging ~100 runs a day (a mix of per-minute, hourly and daily schedules) = ~1 B executions/day ≈ 12 k/s average.
  • Peak burst: ~100 k/s. Everything cron lands on the minute, and a large share on the hour. If 30 % of daily executions are hourly jobs clustered in the first second of the hour, the instantaneous rate is ~100 k/s: roughly eight times the average.
  • Due-index scan size: ~100 k rows/s. A scan every second for jobs due in the next window returns the burst-sized set: ~100 k rows at the top of the hour, a few hundred otherwise. The index must make that a range scan, never a table scan.
  • Execution record storage: ~300 GB/day. 1 B executions × ~300 B (job id, attempt, start, end, status, error) = ~300 GB/day, retained 30 days hot then rolled up. Execution history is far larger than the jobs themselves.
  • Workers needed: ~2 000. Average execution 200 ms of work; 12 k/s × 0.2 s = 2 400 concurrent executions. At ~20 concurrent per worker that is ~120 workers for the average and ~1 000 for the peak, so the queue absorbs the burst rather than the fleet.
  • Lease renewal traffic: ~2 k/s. ~2 400 concurrent executions renewing a lease every 30 s = ~80/s normally, rising with long jobs. Trivial, which is what makes leases affordable as the ownership mechanism.

Components

  • Tenant service: The application that registers jobs and receives their executions, usually as an HTTP callback or a message. It owns the idempotency of its own handler, which is the other half of the correctness story.
  • Schedule API: Create, update, pause, delete, trigger now and backfill. Validates the cron expression and time zone, computes the first due time, and writes the definition.
  • Job store (sharded SQL, index on next_run_at): One row per job: schedule, payload, target, retry policy, concurrency policy, and next_run_at. The index on (shard, next_run_at) is the whole design: every dispatcher query is a range scan on it.
  • Dispatcher (one owner per partition): Wakes each second, claims jobs due in the next window from its partitions, enqueues an execution for each, and advances next_run_at. Partition ownership is leased, so exactly one dispatcher claims any given job.
  • Near-term timer wheel (in-memory, next 60 s): Jobs due within the next minute held in a hierarchical timer wheel so firing is O(1) per tick rather than a database query per second. The store remains the authority; this is a latency optimisation that can be rebuilt at any time.
  • Execution queue (partitioned by tenant): Absorbs the top-of-the-hour burst and decouples deciding from doing. Partitioned per tenant with weighted draining so one tenant's midnight flood cannot starve everyone else.
  • Worker pool: Takes an execution, acquires a lease, invokes the target, renews the lease while it runs, and reports the outcome. Stateless and autoscaled on queue depth.
  • Lease store (Redis / etcd with TTL): execution_id → worker, with a short TTL renewed by a heartbeat. A worker that dies stops renewing and its lease expires, which is how work is recovered without anyone having to detect the death directly.
  • Job target: The thing actually run: an HTTP endpoint, a queue message, a container. Given an idempotency key derived from the execution so a duplicate delivery is recognisable as one.
  • Execution history (time-partitioned): One row per attempt with status, timing and error. The answer to "did it run and what happened", and the source of the per-job dashboards.
  • Retry scheduler: Turns a failure into a future execution with exponential backoff and jitter, up to the policy limit, then dead-letters. Reuses the same due-time mechanism rather than inventing a second one.
  • Reaper: Finds executions whose lease expired without an outcome and requeues them. The only thing standing between a worker dying and a job silently never running.
  • Monitoring & alerts: Lateness distribution, failure rate per tenant, dead-letter depth, and missed-window alerts. A scheduler that is quietly an hour late looks healthy from the inside unless lateness is measured explicitly.

User flows

  1. A daily job fires and runs. The happy path, and the shape of the whole system: decide, enqueue, lease, invoke, record, reschedule.
    1. A tenant registers "every weekday at 09:00 Europe/London". The API validates the expression, resolves the time zone, and computes the first next_run_at in UTC. Storing UTC plus the original zone (never a fixed offset) is what makes the job survive a daylight-saving change.
    2. The dispatcher that owns this partition scans for jobs due in the next window. A range scan on (partition, next_run_at) with a limit. Exactly one dispatcher owns the partition through a lease, so no two dispatchers can claim the same job and no coordination is needed per job.
    3. Jobs due within the minute are loaded into the in-memory timer wheel. Firing from a timer wheel is O(1) per tick and accurate to the millisecond. Polling the database every second would be accurate to a second at best and would put the whole burst on one query.
    4. At 08:00:00 UTC the timer fires and an execution is enqueued. The execution carries a deterministic id (job id plus scheduled time plus attempt) which is the idempotency key the target will see. Deriving it rather than generating it is what makes a duplicate recognisable.
    5. next_run_at is advanced to the next occurrence in the same transaction as the claim. Advancing before enqueueing, in one transaction, means a dispatcher crash cannot fire the same occurrence twice. It can lose the enqueue (which the reaper and the missed-window check catch) and losing is recoverable in a way that double-charging a card is not.
    6. A worker takes it, acquires a lease, and invokes the target. The lease makes ownership explicit and time-bounded. The idempotency key goes in the request so the tenant can reject a duplicate delivery at their end.
    7. The outcome is recorded and the lease released. Status, duration and any error, written before the lease is released so an execution can never be both finished and unrecorded. The history is what the tenant's dashboard and the alerting read.
  2. Midnight: everything is due at once. The scale-breaking case, and it is self-inflicted: cron expressions cluster on round numbers.
    1. At 00:00:00 several hundred thousand jobs become due in the same second. Daily jobs overwhelmingly use midnight, and hourly ones the top of the hour. The distribution of due times is the least uniform thing in the system, and every component has to be designed against that rather than against the average.
    2. Dispatchers claim in bounded batches rather than in one enormous scan. A limit per scan and many partitions scanning in parallel. One unbounded query returning 300 000 rows is a long transaction on the busiest table at the busiest moment.
    3. Executions are enqueued as fast as they are claimed; the queue absorbs the rest. This is why the queue exists. Deciding is fast and bursty; doing is slow and steady. Without the buffer the worker fleet would have to be sized for the peak second of the day.
    4. Workers drain at their sustainable rate; jobs run late, not lost. Lateness is the correct degradation and should be measured as a first-class metric per job. A daily report starting ninety seconds late is fine; the alert threshold belongs to the tenant, not to us.
    5. Per-tenant queue partitions stop one tenant's flood from starving the rest. Weighted draining across tenant partitions with a cap on concurrent executions per tenant. Without it, one customer scheduling a million midnight jobs delays everyone: the noisy-neighbour failure that makes a shared scheduler unusable.
    6. New schedules are jittered at creation to flatten future peaks. For expressions that do not demand an exact second, spread within the minute deterministically by hashing the job id. It costs nothing, it is stable across runs, and it is the cheapest capacity win available.
  3. A worker dies halfway through a job. The failure path. Nobody detects the death directly; the lease simply stops being renewed.
    1. A worker acquires a 60-second lease and starts a job that takes minutes. The lease is short and renewed every 20 seconds or so. Short leases mean fast recovery; renewal is what lets a long job hold ownership without a long lease.
    2. The worker is terminated mid-execution. The genuinely unknowable part: the target may have completed the work, partly completed it, or not started. The scheduler cannot tell, which is why the tenant's idempotency is the other half of the contract.
    3. The lease expires because nothing renews it. Expiry by TTL means no failure detector, no consensus about liveness, and no split brain about who owns the work: the absence of a heartbeat is the signal.
    4. The reaper finds an expired lease with no recorded outcome and requeues the execution. The requeue keeps the same execution id and increments a delivery counter, so the target sees the same idempotency key and can recognise a repeat of work it may already have done.
    5. A new worker picks it up and invokes the target with the same key. If the target completed the first time, it returns its stored result and does nothing. This is the moment the whole at-least-once design either works or produces a duplicate charge, and it works only because the key is deterministic.
    6. Repeated reaping is capped and the execution is dead-lettered with an alert. An execution that has been reaped several times is usually killing its worker (memory, a poison payload) so requeueing forever converts one bad job into a fleet-wide outage. Cap it and alert.
  4. A job fails and is retried. Retries reuse the scheduling machinery rather than inventing a parallel one.
    1. The target returns a 500 and the worker records the failure. The error is stored with the attempt, because "it failed" without the reason turns every support conversation into an investigation.
    2. The retry scheduler computes the next attempt time with exponential backoff and jitter. Base times two to the attempt, with full jitter. Without jitter, a downstream outage that fails ten thousand jobs retries all of them in lockstep and keeps the target down: the retry storm that turns a blip into an incident.
    3. The attempt is written as a future execution in the same due-time index. One mechanism for "run this later", whether it is a cron occurrence, a one-off, or a retry. A separate retry queue with its own timing is a second scheduler to get wrong.
    4. The attempt runs with the same execution id and an incremented attempt number. The attempt number is visible to the target, which is often useful: a handler may behave differently on a fifth attempt, such as skipping an optional enrichment step.
    5. After the policy limit the execution is dead-lettered and the tenant is alerted. Dead-lettered, not deleted: the payload is kept so it can be inspected and replayed after a fix. A missed occurrence must never be invisible, because nobody notices an absence.
    6. A tenant-wide failure rate above a threshold pauses that tenant's retries. A circuit breaker per tenant target. If every job for one tenant is failing, their endpoint is down, and continuing to retry a million jobs helps nobody and hurts them.
  5. The clocks go back. The correctness case people forget. "09:00 every day" is ambiguous twice a year and the answer must be deliberate.
    1. A job is defined as 01:30 daily in a zone that moves its clocks. On the spring-forward day 01:30 does not exist; on the autumn day it happens twice. Storing a fixed UTC offset instead of the zone silently drifts the job by an hour for half the year.
    2. The next occurrence is computed in local time, then converted to UTC. Always in that order, with a current time-zone database. Computing in UTC and adding an offset gets the transition days wrong, which is the bug that reaches production every March.
    3. A skipped local time is resolved by a documented rule. Most schedulers fire at the first valid instant after the gap: 03:00 when the clocks jump from 02:00. What matters is that it is documented and tested, not which rule you pick.
    4. A repeated local time fires once, not twice. Because next_run_at is a UTC instant and is advanced strictly forwards past the second occurrence, the duplicate cannot be generated. The monotonic UTC cursor is what makes this correct by construction rather than by a special case.
    5. A time-zone database update shifts future occurrences and they are recomputed. Zones change by legislation with little notice. A job pinned to a stale database fires at the wrong local time, so updates trigger a recompute of stored next_run_at values and the shifted set is reported.

Deep dives

Finding the jobs that are due

How do you find, every second, which of ten million jobs should run now?

This is the core loop, and the naive version (scan the table for next_run_at <= now) is either a full scan or a hot index, every second, forever.

The distribution makes it worse: due times cluster on round numbers, so the query that returns nothing for 59 seconds returns hundreds of thousands of rows on the sixtieth.

  • Indexed due-time column, partitioned, with an in-memory timer wheel for the near term chosen
  • Poll the database every second, no timer wheel situational: accuracy requirements measured in minutes, where a 30-second poll is ample
  • Delayed-delivery queue (SQS-style) as the scheduler situational: one-off short-horizon tasks (expire this hold in ten minutes) where a managed timer is enough
  • A timer wheel holding every job in memory rejected

The answer: A next_run_at column indexed with the partition key, scanned by the owning dispatcher once a second for a 60-second look-ahead with a bounded batch size, feeding a hierarchical timer wheel that fires with millisecond accuracy. The claim and the advance of next_run_at happen in one transaction, so a crash loses an enqueue rather than duplicating an occurrence. Creation-time jitter spreads jobs within their minute where the expression allows.

A dispatcher dies holding a loaded wheel. What happens?

Its partition lease expires and another dispatcher takes over, scans the store and reloads the next 60 seconds. Jobs due during the gap are found immediately because next_run_at is already in the past, so they fire late rather than not at all. The store being authoritative is what makes that safe.

Why claim and advance in one transaction?

So that the occurrence can never fire twice. If you enqueued first and advanced afterwards, a crash between the two re-fires it on the next scan. Advancing first means a crash loses an execution (detectable by the missed-window check and recoverable by backfill) and losing is a far better failure than duplicating.

How do you handle a job scheduled 40 days out?

Identically. It sits in the store with a far-future next_run_at and is invisible to the scan until its day arrives, costing nothing until then. That is the advantage of an indexed due time over any in-memory or delay-queue approach, both of which struggle with long horizons.

The index is hot at midnight. How do you spread it?

Partition on a hash of the job id so the midnight rows are spread across partitions rather than adjacent in one index. Add creation-time jitter within the minute so the due times themselves are spread. The remaining concentration is absorbed by the queue rather than by the database.

Running a job exactly once

Is exactly-once achievable, and if not, what do you promise instead?

Exactly-once execution across a network is not achievable. A worker can invoke a target, and die before recording the outcome, and nothing in the system can distinguish that from never having invoked it.

Every honest scheduler therefore picks a side. At-most-once loses executions silently. At-least-once duplicates them, and duplicates can be made harmless with an idempotency key, so that is the side to pick, provided you say so and provide the key.

  • At-least-once delivery with a deterministic idempotency key chosen
  • At-most-once situational: best-effort work where a duplicate is harmful and a miss is not: a cache warm, a metrics roll-up
  • Two-phase commit between scheduler and target rejected
  • Transactional outbox inside the target situational: first-party jobs inside your own services, and it is what the idempotency key is designed to enable

The answer: At-least-once delivery with an execution id of job_id:scheduled_time:attempt, passed to the target as an idempotency key and stable across reaps and requeues. Leases prevent concurrent duplicates in the common case; the key handles the uncommon one. Documentation says at-least-once plainly and tells tenants how to deduplicate, and the duplicate-delivery rate is monitored so a regression in the lease path is visible.

Why does the key include the scheduled time rather than a random id?

So it is derivable. Both the original delivery and any reap or retry of the same occurrence produce the same key without anyone having to remember one, which is what lets the target deduplicate. A random id would make every delivery look new, which defeats the whole mechanism.

Should the attempt number be in the key?

It is in the payload but not in the deduplication key, and the distinction matters. A reap of attempt 1 must carry attempt 1's key, or the target sees a new key and runs again. A deliberate retry after a genuine failure is a new attempt and should run: it is a different occurrence of work, not a duplicate delivery.

A tenant's target is not idempotent. What do you do?

Tell them, loudly, in the documentation and at registration time, and offer a concurrency policy of "forbid" plus a "no automatic retry" option so the exposure is bounded to reap duplicates only. You cannot make an arbitrary endpoint safe from the outside; the honest thing is to make the guarantee explicit rather than to imply one you cannot provide.

How would you measure duplicate deliveries?

Count deliveries per execution id in the history: anything above one is a duplicate. A low background rate from reaps is expected; a rise means leases are expiring too aggressively or workers are being killed, and it is the earliest signal that the correctness story is degrading.

Ownership without a failure detector

How does the system know a worker is still alive and still owns a job?

Two workers running the same execution is the duplicate the design is trying to avoid. Preventing it means a notion of ownership that survives crashes, pauses and partitions.

The trap is treating this as a liveness problem. You cannot reliably tell a dead worker from a slow one, so any design that asks "is it alive?" is asking an unanswerable question. Leases sidestep it by asking "does it still hold a valid lease?", which has a definite answer.

  • Short TTL leases with heartbeat renewal, and a reaper for expiries chosen
  • Queue visibility timeout (SQS-style) chosen
  • Distributed lock via consensus (etcd, ZooKeeper) situational: a small number of long-running, high-value singleton jobs where the cost per acquisition is irrelevant
  • Worker heartbeats to a central registry, with a supervisor detecting death rejected

The answer: Per-execution leases with a 60-second TTL renewed every 20 seconds while the job runs, held in a small fast store. A reaper requeues executions whose lease expired without a recorded outcome, keeping the execution id so the delivery is recognisably a repeat. Leases are advisory rather than authoritative (the target's idempotency is what makes a stale-lease duplicate harmless) and the number of reaps per execution is capped so a poison job cannot cycle forever.

A worker is paused by garbage collection for 90 seconds, then resumes. What happens?

Its lease expired, the reaper requeued the work, and another worker may already be running it. The resumed worker must check its lease before doing anything with an external effect, and ultimately the idempotency key is what prevents the duplicate. This is the classic stale-lease scenario and it is why the guarantee is at-least-once rather than exactly-once.

How do you choose the TTL?

A trade between recovery time and false expiry. Too short and ordinary pauses cause spurious reaps and duplicate deliveries; too long and a dead worker's job sits idle. A minute with 20-second renewals gives three chances to renew before expiry, which survives a transient blip while keeping recovery inside a minute.

Can a job run longer than any reasonable TTL, say six hours?

Yes, because renewal decouples job duration from lease duration. The job holds a 60-second lease renewed for six hours. What does break is a job that blocks its own heartbeat thread, which is why renewal must be on a separate thread or process from the work.

The lease store goes down. What happens?

No new leases can be acquired, so execution stops and the queue backs up: the scheduler is unavailable rather than incorrect, which is the right way round. Continuing without leases would mean duplicate execution, and that is a worse failure than a delay. The lease store is replicated for exactly this reason.

Stopping one tenant starving the rest

How do you keep a customer with a million midnight jobs from delaying everyone else?

A shared scheduler has a noisy-neighbour problem by construction: due times are chosen by tenants, and tenants choose midnight. One customer's bulk schedule can occupy the whole worker fleet.

The requirement is not equal service; it is that a tenant's lateness should depend mostly on their own behaviour.

  • Per-tenant queue partitions with weighted fair draining and concurrency caps chosen
  • One shared FIFO queue rejected
  • Dedicated worker pools per tenant situational: a handful of large enterprise customers paying for isolation, often as a compliance requirement
  • Rate limiting at the API instead of at execution situational: as a complement (a cap on jobs per tenant and on creation rate) never as the only mechanism

The answer: Queue partitions per tenant, drained by workers with weighted fair queueing and a per-tenant cap on concurrent executions. Dispatcher partitions are also assigned by a hash of tenant and job id so one tenant cannot dominate a single dispatcher's scan. Quotas on total jobs and creation rate limit the blast radius at the source, and very large tenants can be given dedicated worker pools as a paid tier.

How do you set the per-tenant concurrency cap?

From the plan, with a floor generous enough that a small tenant never notices it. It should be visible to the tenant and reported as a metric ("you were capped for 4 minutes at midnight") because a silent cap looks like an outage from their side, and that is a support ticket you have caused.

Fair queueing across a hundred thousand partitions sounds expensive.

You do not poll every partition. Keep a ready set of partitions with work and draw from it with weights, so cost scales with active tenants rather than with total tenants. At midnight the active set is large, but it is still the set with work, not the whole tenant list.

A tenant's jobs are slow and hold workers for minutes each. Is the cap enough?

A concurrency cap bounds workers held, which is the resource that matters, so yes for isolation. Separately, a maximum execution duration should be enforced and the job terminated and marked failed beyond it, or one tenant's hung endpoint permanently occupies its full concurrency allowance.

What is the metric that tells you isolation is working?

Lateness percentiles per tenant, correlated against other tenants' load. If tenant B's p99 lateness rises when tenant A schedules a million jobs, isolation has failed regardless of what the design says. That correlation is the test worth alerting on.

When a run outlives its interval

A job scheduled every five minutes takes seven. What should happen?

Every scheduler meets this, and the default behaviour decides whether a slow job degrades gracefully or destroys itself. Left alone, overlapping runs pile up until the job is running continuously with dozens of copies.

There is no universally right answer (it depends on whether the job is idempotent and whether a missed run matters) so it has to be a policy on the job, with a safe default.

  • A per-job concurrency policy: forbid, allow, or replace, defaulting to forbid chosen
  • Always allow concurrent runs situational: genuinely stateless, idempotent work where each run is independent: a health probe
  • Always forbid, with no option situational: an internal scheduler where every job is known and none benefit from overlap
  • Queue the occurrence and run it after the current one situational: ordered per-entity work (process this account's events in sequence) with a bounded queue and an alert on depth

The answer: A concurrency policy per job, defaulting to forbid: a due occurrence whose previous run is still active is skipped and recorded as skipped rather than queued. Allow permits overlap for jobs that declare themselves safe, and replace cancels the running attempt and starts the new one for jobs where only the latest matters. Enforcement uses a per-job lock in the lease store, and a run that exceeds its maximum duration is terminated so a hang does not silently stop the job forever.

Under "forbid", how does the tenant know runs are being skipped?

Skips are first-class rows in the execution history with a reason, and a run of consecutive skips raises an alert. A skip that is only visible as an absence in a list is the kind of failure nobody notices for a month, which is exactly what this system exists to prevent.

What if the job is down for two hours? Do you backfill?

A per-job policy again, and the default is no: firing 120 accumulated occurrences at once is usually worse than skipping them, and for something like a report it is meaningless. Jobs that genuinely need every window offer an explicit backfill with a cap on how many are replayed, and an operator can trigger one manually.

Does "replace" risk the same duplicate problem as a reap?

Yes, and it has to be honest about it. Cancelling a running job does not guarantee the target stopped, so the replacement may run concurrently with work that is still finishing. Replace is only appropriate for idempotent jobs where the latest result wins, and the documentation has to say so.

How is "forbid" enforced across workers?

A per-job lock in the lease store, acquired before invoking and released on completion, with a TTL so a dead worker does not block the job forever. It is the same mechanism as the execution lease at a different granularity, and reusing it rather than inventing a second locking scheme is what keeps the failure modes understandable.

Related