Vibe Engines
YouTube

Vibe Engines is free, interactive AI engineering & system design — runs in your browser. Pick a learning path

System Design

Design a Job Scheduler

Step 1 / 9

Learn system design by building a distributed job scheduler / cron step by step.

The numbers to beat1sscan intervalqueueabsorbs burstsNworkers

The whole design, in writing

Every step of the build above, written out: the problem each piece solves, the option that was taken and the ones that were not, the numbers, and how it fails in production.

The big idea

What is a job scheduler?

Tons of work shouldn’t happen now — send this reminder at 9am, bill this card monthly, retry that webhook in 30 seconds, run this report nightly. You need something that reliably runs the right job at the right time, even across thousands of machines and through failures.

Clientsubmit a job
New in this step: Client.

Split the problem in two: remember when each job should run, and run it when the time comes. Persist schedules durably, scan for what’s due, and hand due jobs to workers. The hard parts are not missing a job and not double-firing one.

What the new pieces do

Clientclient
Registers work to run later — once at a time (“email me at 9am”) or on a recurring schedule (“every night”). Then it walks away and trusts the scheduler.

Step 1 · Remember the work

Register & persist jobs

A job submitted now might need to run next month. If the schedule lives only in memory, a single restart forgets it — and a forgotten payroll run is a very bad day.

Scheduler APIJob Store
New in this step: Scheduler API, Job Store. · swipe to pan the diagram

A job submitted now might need to run next month. Where does the schedule live?

  1. A single restart forgets every in-memory timer — and a forgotten payroll run is a very bad day. Schedules must survive crashes, so memory alone won’t do.

  2. That just moves the problem to the client and assumes it stays alive, online and trustworthy for a month. The whole point is to hand off and walk away.

  3. Persist what/when/how-often before acknowledging, indexed by next run time. Now the system can crash and recover without losing a job, and "what’s due?" is a cheap range read.

A Scheduler API accepts job definitions (what, when, how often) and writes them to a durable Job Store, indexed by next run time. One-off jobs store a single time; recurring jobs store a cron expression and compute the next time after each run.

Why this piece earns its place

The solution says to store the cron expression, but a cron expression on its own is not a rule — it is a rule missing its clock. “Every night” is a local hour, and unless the timezone it was written in is persisted beside it, a daylight-saving shift silently moves every fire by the offset for part of the year, and on the night the clock jumps the local hour the job was waiting for either happens twice or does not exist at all. The payload deserves the same suspicion. A job registered today may not fire until next month, so anything captured by value — a price, an address, a feature flag — is a stale snapshot by the time a worker reads it. Store a reference and resolve it at execution time for anything the business is allowed to change underneath you. The store is holding intent that has to survive time, not a photograph of the moment someone pressed submit.

What the new pieces do

Scheduler APIbackend
Accepts job definitions: what to run, when, how often, and with what payload. Validates and persists them; it doesn’t run anything itself.
Job Storestore
The durable record of every job, its next run time, and its status. Indexed by next-run-time so “what’s due?” is a cheap range query.

Step 2 · Notice it’s time

Scan for what’s due

Jobs sit in the store with future run times. Something has to keep checking the clock and notice the instant a job becomes due — without scanning the entire table every second.

Job Storeschedule + stateDue Scannerwhat’s due now
New in this step: Due Scanner.

Jobs sit with future run times. How do you notice the instant one is due, without scanning the whole table every second?

  1. Scanning hundreds of millions of rows every second is enormous wasted I/O. You should touch only the jobs that are actually due, not the entire table.

  2. Because the store is indexed by run time, "what’s due?" is a cheap bounded range read — pull the due ones, leave the rest untouched. A short poll keeps latency low and cost flat.

  3. Millions of live OS timers don’t fit in one process and vanish on restart — back to the durability problem. A polled durable index survives crashes and scales.

A Due Scanner periodically queries the store for jobs whose next_run_time ≤ now. Because the store is indexed by run time, that’s a cheap bounded range read — pull the due ones, leave the rest untouched.

Why this piece earns its place

The poll interval is the knob this step introduces, and it is a floor on lateness before it is anything else: a job can only be as punctual as the gap between scans, so “every second” is really a promise that a job fires within about a second of its time. Shorten it and you pay for range reads that mostly come back empty, all aimed at the same narrow end of one index — the hottest, most contended range in the store, hit by every scanner you run. Lengthen it and everything on the system is uniformly late. The part people forget is the bound on the read. A query for next_run_time ≤ now has no natural size: after an outage, or when a shard falls behind, “due” isn’t the last tick’s worth of work, it is every job that came due while you were down. Without a limit per scan and a stable sort that resumes where the previous batch stopped, the first scan after recovery tries to load the whole backlog in one query and takes the scanner down again, right as it is needed most.

What the new pieces do

Due Scannerservice
Periodically asks the store for jobs whose run time has arrived and moves them toward execution. The clock-watcher that turns time into action.

Back of the envelope

index by next_run_time
"what’s due?" = a bounded range read
poll ~every 1s
low latency, flat cost — touch only due jobs
future jobs untouched
cost independent of total job count

Step 3 · Don’t run it inline

Decouple detect from execute

If the scanner also executed jobs, a slow job (or a flood of due jobs at the top of the hour) would stall scanning — and the next batch of due jobs would be late. Detection and execution have completely different scaling needs.

Ready Queuedue jobsWorker Poolexecute jobs
New in this step: Ready Queue, Worker Pool.

At the top of the hour thousands of jobs come due at once and some run slowly. What stops scanning falling behind?

  1. If the scanner executes inline, one slow job or a burst stalls scanning and the next due batch is late. Detection must stay punctual; execution can lag.

  2. A longer interval makes every job fire late and still stalls when a burst hits. The fix is to separate detecting from doing, not to slow detection.

  3. A Ready Queue between them keeps scanning fast and punctual while workers scale out to absorb bursty execution. Each side scales and fails independently.

The scanner just drops due jobs onto a Ready Queue; a separate Worker Pool drains it and runs them. Now scanning stays fast and punctual while workers scale out to absorb bursty execution load.

Why this piece earns its place

The queue removes the stall and introduces a new problem: the scanner now runs on a timer that is faster than the work. A job sits in the store still marked due while workers are busy, so the next scan a second later reads the same row and enqueues it again, and the scan after that makes a third copy — one due job becomes a flood, and the harder the workers are struggling the faster you pile on. So enqueueing has to change the job’s state in the same breath: the scanner claims the row — flipping its status, or pushing next_run_time out by a claim window — and only then writes to the queue, so the following scan simply doesn’t see it. The queue itself has to be durable for the same reason the store is. An in-memory broker sitting between a persisted schedule and a persisted result quietly turns “we never forget a job” back into “we forget everything in flight”, and a worker outage — exactly when its buffer is deepest — is when you find that out.

  • 1sscan interval
  • queueabsorbs bursts
  • Nworkers

What the new pieces do

Ready Queuebus
Holds jobs that are due right now. Decouples deciding when from doing the work, and lets execution scale and retry independently.
Worker Poolworker
Pulls due jobs off the queue and actually runs them. A stateless fleet you scale to match how much work comes due at once.

Step 4 · Run it once, really

At-least-once + idempotency

Queues deliver at least once, and a worker can crash after doing the work but before marking it done — so the job runs again. Charging a card twice because of a retry is unacceptable.

Worker Poolidempotent executeExecution Logstatus · idempotency
New in this step: Execution Log.

A worker crashes after doing the work but before marking it done, so the job is redelivered. How do you not run it twice?

  1. Mark-then-run means a crash after marking but before doing silently skips the job (at-most-once) — a missed payroll run. You can’t avoid double-execution by risking zero-execution.

  2. Each execution carries a stable key; the worker checks the log first and writes the result after, so a redelivered job is recognized and skipped. At-least-once delivery + dedup = effectively-once.

  3. Exactly-once delivery is impossible across worker crashes — the worker may act, then die before acking. Make the execution idempotent instead of trusting the transport.

Give every execution an idempotency key and record outcomes in an Execution Log. A worker checks the log before acting and writes the result after, so a redelivered job is recognized and skipped. Mark the job done in the store and compute its next run.

Why this piece earns its place

All of this rests on what the key is made of, and it is easy to build one that dedupes nothing. A key minted per delivery is unique every time, so every redelivery looks new. A key that is only the job id fails the other way: it collides with tomorrow’s run of the same recurring job, and the second night is skipped as a duplicate. The key has to name the occurrence — job id plus the scheduled fire time, or a run sequence — so every delivery of one firing collides and no two firings ever do. Then there is the gap the log cannot close. Unless the effect and the log entry commit together, a worker can act and die before recording it, which is the case that opened this step. What you can do is write the key before acting and the outcome after, so a crash leaves a row saying attempted rather than nothing, and recovery has something to ask about — replay the key against the downstream that owns the effect instead of guessing. And give the log a retention policy: it grows with every execution forever, and it sits on the hot path of every one of them.

What the new pieces do

Execution Logstore
Records each run’s outcome and its idempotency key, so a job that gets delivered twice is executed once — the dedup ledger.

Back of the envelope

at-least-once delivery
crash-after-work ⇒ redelivery is normal
stable key + execution log
check before, record after ⇒ run once
effectively-once
exactly-once delivery is impossible across crashes

Step 5 · Don’t fire five times

Leader election

For availability you run several scheduler instances. But if all of them scan and enqueue the same due job, it fires once per instance — the duplicate problem moved upstream of the workers.

Due Scannerwhat’s due nowWorker Poolidempotent executeCoordinatorleader election
New in this step: Coordinator.

You run several scheduler instances for availability. How do you stop all of them firing the same due job?

  1. Relying on downstream dedup for every job wastes work and leans entirely on idempotency being perfect. Better not to enqueue the same job N times in the first place.

  2. A single instance is a single point of failure — when it dies, nothing fires until someone restarts it. You need multiple instances for availability without multiple fires.

  3. Only the leaseholder scans-and-enqueues a partition; standbys take over instantly if its lease expires. Coordinating who-scans is far cheaper than coordinating every job — single-firing plus fast failover.

Use leader election (via a lease in a consensus store / distributed lock) so only one instance owns scanning for a given partition of jobs at a time. Others stand by, ready to take over instantly if the leader’s lease expires.

Why this piece earns its place

A lease buys failover; it does not buy mutual exclusion, and that is worth saying out loud because the diagram makes it look airtight. A leader can stop for a long garbage-collection pause, lose the network for a moment, or be descheduled. Its lease expires, a standby is elected and starts scanning, and then the original wakes up with no idea any time passed — and its very next act is to enqueue the batch it was holding. For that window two instances both believe they own the partition. A shorter lease doesn’t close the gap; it only makes it more frequent. What you can do is make the collision detectable: the leaseholder carries a fencing token that increases with every election and stamps it on each claim it writes, and the store refuses any write carrying a token older than one it has already accepted, so the woken leader’s work is rejected instead of doubled. Lease duration then becomes a plain availability knob — short means fast takeover and elections triggered by ordinary pauses, long means nothing in that partition fires until it expires. This is also why idempotency still matters underneath: the coordinator reduces double-fires, it cannot abolish them.

What the new pieces do

Coordinatorservice
Ensures only one scheduler instance scans-and-enqueues a given partition, so a due job isn’t fired five times by five running schedulers.

Step 6 · When jobs fail

Retries, backoff & DLQ

Jobs call flaky downstreams that time out or error. Retrying instantly and forever just hammers a struggling service and clogs the workers with a job that will never succeed.

Ready Queuedue jobsWorker Poolidempotent executeExecution Logstatus · idempotencyCoordinatorleader electionRetries + DLQbackoff
New in this step: Retries + DLQ.

A job calls a flaky downstream that errors. How do you retry without hammering it or looping forever?

  1. Instant infinite retries hammer the struggling downstream and clog workers with a job that may never succeed. Retries need spacing and a limit.

  2. Re-queue with growing delays (protecting the downstream), cap attempts, and send exhausted jobs to a dead-letter queue for humans — instead of one poison job looping forever and clogging the pipeline.

  3. Dropping on first failure loses work that was a transient blip away from succeeding. Transient failures deserve bounded retries; only persistent ones get parked in the DLQ.

On failure, re-queue with exponential backoff (and jitter) up to a max attempts. Jobs that exhaust their retries move to a dead-letter queue for inspection instead of looping. Successful retries record normally in the execution log.

Why this piece earns its place

Backoff assumes the failure is worth repeating, and most retry budgets get burned on failures that never were. A timeout or a refused connection is a blip; a malformed payload, a deleted account, a permission that was revoked will fail identically on every attempt — and because the delays grow, the last attempts are the longest, so a job that was hopeless from the first try occupies the pipeline longest before anyone is told about it. Classify the failure where it is caught: retry the transport-shaped ones, dead-letter the argument-shaped ones immediately. The other half is that a DLQ is where the design quietly stops being automatic. Nothing drains it, so without an alarm on its depth it is a silent drop with better paperwork — and the jobs in it are precisely the ones somebody wanted. Give it an owner, an alert and a replay path, and make replay re-enter through the original occurrence’s key, because a timeout is ambiguous: the job that “failed” may have already done its work, and a replayed payment that quietly succeeded the first time is the exact outcome this whole design exists to prevent.

What the new pieces do

Retries + DLQstore
Failed jobs are re-queued with exponential backoff; ones that keep failing land in a dead-letter queue for humans to inspect, not retried forever.

Back of the envelope

delay = base × 2^attempt + jitter
protects the flaky downstream
cap at max attempts
then dead-letter — don’t loop forever
DLQ ⇒ a human inspects
one poison job can’t clog the pipeline

Step 7 · Scale & time

The sharp edges

Polling a store with hundreds of millions of jobs every second is wasteful, and machine clock skew means two nodes disagree on “now”. At scale, a single scanner is also a bottleneck.

ClientScheduler APIJob StoreDue ScannerReady QueueWorker PoolExecution LogCoordinatorRetries + DLQ
The system as it stands at this step. · swipe to pan the diagram

Replace second-by-second polling with a hierarchical time wheel for efficient near-term scheduling, and shard jobs (by id or tenant) so many leader-elected scanners run in parallel. Treat run times as approximate and lean on idempotency to absorb clock skew.

Why this piece earns its place

A time wheel is an in-memory structure — the thing step 1 refused to trust — so it only works if it is treated as a cache of the near future rather than the schedule itself. The store stays the record; the wheel holds a bounded horizon of imminent jobs, and a slower sweep promotes rows into it as that horizon slides forward. The horizon is the knob. Keep it short and the sweep runs almost constantly, which is polling again with extra steps; stretch it and the wheel holds a large slice of the schedule in memory, which costs memory and makes it a bigger thing to rebuild. Rebuild is the part to say out loud, because it happens far more often than a crash: every failover hands a partition to a standby whose wheel is empty, so a new leader’s first act is reloading its horizon from the store, and takeover is only as fast as that read. And a job edited after it was promoted now exists in two places — the wheel has to be invalidated on write, or you cheerfully fire the version of the job the user already cancelled.

You did it

You just designed a job scheduler.

ClientScheduler APIJob StoreDue ScannerReady QueueWorker PoolExecution LogCoordinatorRetries + DLQ
The finished design, end to end. · swipe to pan the diagram

Everything you assembled, in order

  • A Scheduler API persists jobs to a store indexed by next-run-time.
  • A due scanner polls that time index for jobs that have come due.
  • A ready queue decouples due-detection from a scalable worker pool.
  • Idempotency keys + an execution log make at-least-once delivery effectively-once.
  • Leader election per partition stops multiple schedulers double-firing a job.
  • Exponential backoff and a dead-letter queue handle failures without looping.
  • Time wheels and sharding scale scanning; idempotency absorbs clock skew.

Where an interviewer pokes next

Getting the boxes right is the easy half. These are the questions that separate a candidate who drew the diagram from one who has run the thing. Answer each one out loud before you open it.

  1. How do you guarantee a job is never missed?

    Durability + idempotent retry. The schedule is persisted before the API acks, so a crash can’t forget it; the scanner re-reads due jobs from the store on restart; and at-least-once delivery means a job that wasn’t confirmed executed gets re-enqueued. The bias is deliberately toward running again (dedup catches it) rather than risking a miss.

  2. A recurring job runs longer than its interval — what happens?

    You pick an overlap policy: skip the next fire if the previous is still running (common for idempotent maintenance), queue it (risking pile-up), or allow concurrency. Most schedulers default to "no overlap per job" via a per-job lock, so a slow nightly report doesn’t start a second copy before the first finishes.

  3. How precise is the firing time — can a job run exactly at 9:00:00?

    No — it’s approximate. Poll interval, queue wait, clock skew and worker availability add jitter, so a "9am" job fires within a small window, not to the millisecond. Treat run times as "about now" and make jobs tolerate it; for hard real-time, a scheduler is the wrong tool.

  4. How do you handle a thundering herd all scheduled for midnight?

    Cron-at-midnight clusters everything on one tick. Spread it with jitter on next-run-time, shard scanning so multiple leaders share the load, let the ready queue absorb the burst, and scale workers horizontally to drain it. The queue + worker decoupling exists precisely for these spikes.

  5. Why not just use OS cron on one box?

    cron has no durability (a reboot near the fire time can miss it), no retries/backoff, no dedup, no horizontal scale, and is a single point of failure. A distributed scheduler adds persistence, at-least-once + idempotency, leader election and sharding — everything cron lacks once you have many jobs across many machines.

Check yourself — the answers, and why

Eight steps in, these are the calls you should be able to make cold. Pick one, then read why.

  1. The schedule is persisted to a durable store mainly so that…

    The schedule is the asset — persist before ack, index by next-run-time, survive any crash.

  2. "What jobs are due?" is cheap because the store is…

    A time-sorted index makes due-detection a bounded range query, not a table scan.

  3. A queue sits between the scanner and workers so that…

    Detection must stay real-time; execution can lag and scale — decouple them.

  4. A job runs effectively-once via…

    Delivery can’t be exactly-once across crashes; make the execution idempotent on a stable key.

  5. Leader election per partition exists to…

    One leaseholder scans a partition; standbys fail over — single-fire plus availability.

Worked example: one nightly invoice run that times out, retries, and still charges once

A tenant registers 0 2 * * * in Europe/London: run invoicing every night at 02:00 local time. Tonight the first attempt times out. The second attempt succeeds, but its worker dies before acking. The 1 s scan interval and the backoff formula base × 2^attempt + jitter come from the design. The base, the batch limit and the claim window are illustrative.

StepWhereWhat happensNumbers
1Scheduler API → Job StoreThe cron string, its timezone and a payload reference are persisted, and only then acked. next_run_time = tonight 02:00 local.1 row; ack after the write
2Due Scanner (leader)The partition's leaseholder range-reads next_run_time ≤ now, with a per-scan limit and a stable sort.fires within ~1 s; limit 500 rows, illustrative
3Due Scanner → Ready QueueIt claims the row by pushing next_run_time out and stamping its fencing token, then enqueues. The next scan doesn't see the row again.claim window 5 min, illustrative
4Worker → Execution LogThe key is job id + 02:00 fire time. It's written as attempted before the call. The billing downstream times out.attempt 1 failed
5Retries + DLQA timeout is transport-shaped, so the job is re-queued with backoff. A malformed payload would go straight to the DLQ instead.base 2 s, illustrative: 4 s, 8 s, 16 s
6Worker (attempt 2)The charge succeeds. The worker dies before writing the outcome or acking, so the queue redelivers.1 effect, 2 deliveries
7Execution Log (redelivery)The key shows attempted. The worker asks the billing system about that key instead of charging again, records done, and acks.charges: 1
8Job StoreThe row is marked done. The next fire is computed in local time, so a daylight-saving night stays at 02:00 London.next run: tomorrow 02:00 local

The fire time is approximate by design. The poll gap, the queue wait and row 5's backoff all add up, and the backoff dominates tonight's lateness. Correctness lives in rows 4 and 7: the key names the occurrence, so a redelivery collides and tomorrow's run doesn't (Step 4). At 10× jobs, the top-of-the-hour range read breaks first, because every tenant's 02:00 lands on one end of one index. The per-scan limit (Step 2), the queue absorbing the burst (Step 3), and sharded scanners with a time wheel (Step 7) carry it.

How you’d open this design in an interview

Before any boxes: agree what it must do, pin the qualities that shape everything, then build — naming each trade-off as you make it. The walkthrough above is that exact order.

What it must do

Agree on these before drawing a single box.

  • Register: submit a one-off (run at 9am) or recurring (every night) job.
  • Fire on time: run the right job when its time arrives.
  • Execute once: run a due job’s effect exactly one time, even through retries.
  • Retry: back off flaky failures, dead-letter the hopeless ones.
  • Scale: handle hundreds of millions of jobs across many machines.

The qualities that shape everything

Each one names the mechanism that buys it.

Never forget a job across a restart
Persist every schedule to a durable job store indexed by next-run-time before acking, so a crash can recover without losing a job.
Notice due jobs without scanning everything
The due scanner range-queries the time-sorted index (next_run_time ≤ now), touching only due jobs — cost independent of total job count.
Bursty execution never stalls scanning
The scanner enqueues due jobs onto a ready queue and a separate worker pool drains it, so detection stays punctual while execution scales and lags independently.
Run a job’s effect exactly once
Give each execution an idempotency key and an execution log — check before acting, record after — so at-least-once redelivery is deduped to effectively-once.
Many schedulers, no double-fire
Leader election gives one instance a lease to scan a partition; standbys take over on lease expiry — single-writer-per-partition plus fast failover.
Failures without hammering or looping
Re-queue with exponential backoff and jitter up to max attempts; jobs that exhaust retries go to a dead-letter queue for humans instead of looping forever.

The trade-offs you say out loud

Senior signal isn’t the boxes — it’s naming what you gave up and why it was the right price.

A durable job store over in-memory timers

A single restart forgets every in-memory timer — and a forgotten payroll run is a very bad day. Persisting what/when/how-often before acking lets the system crash and recover without losing a job.

Range-query a time index over full-scanning the table each second

Scanning hundreds of millions of rows every second is enormous wasted I/O. A time-sorted index makes "what’s due?" a cheap bounded range read that touches only due jobs.

A queue between scan and execute over the scanner running jobs inline

If the scanner executes, one slow job or a top-of-the-hour burst stalls scanning and the next due batch is late. A ready queue keeps detection punctual while workers absorb bursty execution.

Idempotent execution + dedup over trusting exactly-once delivery

Exactly-once delivery is impossible across a worker that acts then dies before acking. A stable idempotency key checked against an execution log makes at-least-once delivery effectively-once.

Leader election per partition over a single scheduler instance

One instance is a single point of failure — nothing fires when it dies. A lease lets one leader scan a partition with standbys ready to take over: no double-fire and availability, without leaning entirely on downstream dedup.

The answer, out loud

What a strong answer to “Design a Distributed Job Scheduler” sounds like, first question to last trade-off. It is about 7 minutes of talking; the whiteboard and the interviewer fill the rest of the 45. Read it aloud once, then close the page and give it yourself.

  1. 0–3 min

    What we’re actually building

    Let me scope this before I draw anything. A scheduler takes work that shouldn’t happen now — send this reminder at 9am, bill this card monthly, run this report nightly — and promises to run it later. So there are two jobs inside one: remember when, and then run it. Everything interesting lives in the two ways that promise breaks. We can forget a job, which is unforgivable, or we can run it more than once, which for a payment is just as bad. I’ll also assume from the start that this holds hundreds of millions of jobs across many machines, because that rules out a lot of the simple answers.

  2. 3–8 min

    Remember it durably

    The first thing I’d put down is a Scheduler API in front of a durable job store. The client submits what to run, when, and how often; we write that down and only then acknowledge. The order matters, because an ack is a promise, and anything sitting in a timer thread is gone on the next deploy. I’d index the store by next run time — a decision that pays off one step later rather than here. A one-off job stores a single time; a recurring job keeps its schedule and computes the next one after each run.

    Built in step 1: Register & persist jobs
  3. 8–14 min

    Noticing that it’s time

    Now something has to watch the clock, so I’d add a due scanner that asks the store, roughly every second, for jobs whose next run time is at or before now. Because the store is sorted on that column, the question is a bounded range read — it touches the due jobs and nothing else, so it costs about the same whether we’re holding a thousand jobs or hundreds of millions. I want to say why I’m not using timers: a timer per job doesn’t fit in one process and doesn’t survive a restart, which puts us straight back to forgetting things.

    Built in step 2: Scan for what’s due
  4. 14–20 min

    Detecting and executing are different jobs

    The scanner shouldn’t run the jobs it finds. At the top of an hour thousands of things come due at once and some of them are slow, and if detection and execution share the same thread then one slow job makes the next batch late. So the scanner drops due jobs onto a ready queue and a separate worker pool drains it. That split is why a worker outage is survivable here: the scanner keeps noticing, the queue keeps holding, and when the workers come back they drain the backlog. Jobs run late rather than never, and that’s the trade I want.

    Built in step 3: Decouple detect from execute
  5. 20–27 min

    Running it once, for real

    Now the hard part. The queue delivers at least once, and a worker can do the work and die before it says so, so the job comes back. I don’t want to solve that by marking it done first — that turns a double charge into a skipped payroll, and I’d rather run twice than never. Instead I make the execution idempotent: every run carries a key, and the worker checks an execution log before it acts and writes the outcome after. At-least-once delivery plus that check gives me effectively-once. I’d say plainly in the room that exactly-once delivery isn’t something you can buy.

    Built in step 4: At-least-once + idempotency
  6. 27–33 min

    More than one scheduler

    One scheduler is a single point of failure, so I’ll run several — and now all of them see the same due job and all of them enqueue it. I’d rather not lean on the dedup we just built for every single job, so I partition the jobs and use leader election: one instance holds a lease on a partition and is the only thing scanning it, while the others stand by. If a leader dies, its lease expires and a standby picks the partition up within seconds. Coordinating who scans is much cheaper than coordinating every job.

    Built in step 5: Leader election
  7. 33–38 min

    When jobs fail

    Jobs call things that break. I’d re-queue a failure with exponential backoff and jitter so we’re not hammering something that’s already struggling, cap the attempts, and send whatever exhausts them to a dead-letter queue. The cap is the important half. Retrying forever means one poison job sits in the pipeline consuming workers indefinitely, and the dead-letter queue is how we admit a person has to look at this one instead of pretending the system can fix it by trying harder.

    Built in step 6: Retries, backoff & DLQ
  8. 38–42 min

    At scale, and about time

    At the size I mentioned, two things change. Polling one store every second gets wasteful, so I’d shard the jobs and run a leader-elected scanner per shard, and use a time wheel so we only touch what’s about to fire. And I’d be honest about precision: poll interval, queue wait, and clock skew between machines all add up, so a nine-o’clock job fires around nine, not at nine exactly. I design for about-now and let idempotency absorb the disagreement between two machines’ clocks.

    Built in step 7: The sharp edges
  9. 42–45 min

    The trade, and what’s left

    To close: the bias running through all of this is toward running again rather than risking a miss, and we pay for that bias with dedup everywhere — the idempotency key, the execution log, the single writer per partition. That costs storage and a check on the hot path of every execution, and it means a fire time is approximate rather than exact. With more time I’d work through the overlap policy for a recurring job that runs longer than its own interval, and spreading the midnight pile-up, because cron at midnight puts the whole system on one tick.

What this teaches

Learn system design by building a distributed job scheduler / cron step by step. An interactive guide covering the job store, due-time scanning, decoupling detection from execution, at-least-once execution with idempotency, leader election to avoid double-fires, retries with backoff and DLQ, and scaling.

Key takeaways

  • A Scheduler API persists jobs to a store indexed by next-run-time.
  • A due scanner polls that time index for jobs that have come due.
  • A ready queue decouples due-detection from a scalable worker pool.
  • Idempotency keys + an execution log make at-least-once delivery effectively-once.
  • Leader election per partition stops multiple schedulers double-firing a job.
  • Exponential backoff and a dead-letter queue handle failures without looping.
  • Time wheels and sharding scale scanning; idempotency absorbs clock skew.

Concepts covered

  • What is a job scheduler?
  • Register & persist jobs
  • Scan for what’s due
  • Decouple detect from execute
  • At-least-once + idempotency
  • Leader election
  • Retries, backoff & DLQ
  • The sharp edges
built to be scheduled, not memorized — make the calls, kill the leader, run the gauntlet.
Finished this one? 0 / 65 System Designs done
NextDesign a Load Balancer Closest system design to this one

Explore the topic

See this alongside everything else on the same subject — handbooks, system designs, challenges and tools, in one place.

More System Designs