See It Flow
System Design · 14 chapters · ~20 min read

Designing a Distributed Task Scheduler

Scheduling a job to run later sounds like a single cron line until you need it to survive a crashed worker, never double-fire, and still hit its target time within a few seconds at millions-of-jobs scale. This walkthrough goes from a single cron job to a distributed task scheduler — sometimes called a job scheduler — that handles fault tolerance, coordination, and retries as first-class concerns rather than afterthoughts.

Mangesh MandlikLast updated:
Chapter 01

Problem Definition

Every production system needs to run code at a specific point in the future. Send a password reset email in 15 minutes. Generate a weekly report every Monday. Run payroll on the last day of the month. These deferred, time-sensitive jobs are what a task scheduler exists to handle.

The core loop appears simple: store a job with a future execution time, detect when it's ready, hand it to a worker, mark it done. But at scale — millions of jobs, distributed execution, hardware failures, network partitions, and at-least-once delivery guarantees — every naive approach collapses.

Why cron breaks at scale

Cron was designed for single-machine, single-process operation. It has no awareness of distributed systems, no retry logic, no duplicate-execution prevention, no job history, and no horizontal scaling. Whether a crashed cron daemon actually loses jobs scheduled during its downtime depends on the design: a cron process that merely triggers work with no durable backing table genuinely does lose that work. But a cron process that just scans a durable table for run_at <= NOW() will pick up overdue rows again on restart — the real limitations of single-cron are single point of dispatch, coarse (typically minute-level) granularity, no coordination across multiple instances, and hard horizontal scaling, not an inherent guarantee of silent job loss.

Real-world examples

Email notifications

Send a welcome email 1 hour after signup. Trigger reminder sequences at T+1, T+3, T+7 days.

Billing processing

Charge subscription customers on their renewal date. Generate and email invoices.

Data pipelines

Run nightly ETL jobs. Aggregate analytics hourly. Sync data between systems on a schedule.

Infrastructure tasks

Expire old sessions. Clean up soft-deleted records. Rotate API keys. Renew TLS certificates.

Interactive

Visual Design Journey

Watch a single cron job break under scale, duplicate itself under naive failover, and get rebuilt — piece by piece — into a leader-elected, lease-protected scheduler that survives crashes without losing or double-running a single job.

Chapter 02

Requirements

Before designing anything, establish what the system must do and what guarantees it must actually provide — and be precise about what "run at 09:00:00" means, because a distributed scheduler cannot promise the exact instant.

What "scheduled at 09:00:00" actually means

A practical distributed scheduler does not promise execution at the exact timestamp. The precise contract is: the job becomes eligible for execution at or after 09:00:00, and the system targets a bounded scheduling delay. It's worth separating four distinct points in time that are easy to conflate: the scheduled/eligible time (when the job becomes runnable), the dispatch time (when the scheduler hands it to the queue), the worker start time (when execution actually begins), and the completion time. The gap between the first two is dispatch_lag = dispatched_at - scheduled_at, and the gap to actual execution start is start_lag = started_at - scheduled_at — both are SLOs the system should measure and bound, not zero-cost guarantees.

Functional Requirements

Schedule one-time jobs

Accept a task payload and a future execution timestamp. The job becomes eligible for execution at or after that time — the system targets a bounded scheduling delay, not an exact-instant guarantee.

Schedule recurring jobs

Accept a cron expression or interval, plus an explicit timezone. Generate individual execution occurrences from the schedule. Apply a defined misfire policy (skip vs. catch up) if occurrences were missed.

Cancel pending job / job or schedule

Mark a pending job as cancelled before it executes. This is generally reliable before dispatch, race-prone once queued, and cooperative (best-effort) for a job already running — see the Job Lifecycle chapter for the exact mechanics.

Inspect job/schedule state

Query the current status, attempt history, and next-fire time of any job or recurring schedule via API.

Retry failed jobs

Automatically re-enqueue retryable failures with capped exponential backoff and jitter. Limit maximum retry attempts or enforce a deadline. Move exhausted or permanent failures to a Dead Letter Queue.

Pause/resume and update/reschedule

Optionally allow a recurring schedule to be paused without deleting it, and allow a pending one-time job's execution time to be updated before dispatch.

Non-Functional Requirements

Durable acceptance

A successfully created job or schedule must survive process and node failure — accepting a request and then losing it silently is a correctness bug, not an edge case.

At-least-once execution attempts

A job should never be silently dropped. It is acceptable, under crash and retry scenarios, for an execution attempt to happen more than once — idempotent or deduplicated workers are what make that acceptable, not a scheduler-level guarantee that it never happens.

Bounded scheduling lateness

Define an explicit SLO for dispatch_lag under normal and burst conditions, rather than promising unconditional on-time execution.

Horizontal scalability

Support millions of active scheduled jobs and a peak due-job rate well above steady-state average, without a single node or table becoming the bottleneck.

Failure recovery

Any single component — scheduler node, worker, database — can fail without permanently losing or permanently blocking jobs.

Observability

Every dispatch and execution attempt must be logged, with metrics for scheduling lag, dispatch lag, queue depth, retry rate, and DLQ size.

Tenant isolation / fairness (if multi-tenant)

One tenant or job class should not be able to monopolize dispatch or worker capacity and starve others.

It's worth being explicit up front about what the design does and does not promise: jobs may run somewhat late under load (bounded by the scheduling-lag SLO); jobs may run more than once under retry/crash scenarios (idempotency handles this); overlapping recurring executions and missed-recurrence handling are governed by explicit, per-schedule policies rather than one fixed system-wide rule; and cancellation of an already-started execution is best-effort/cooperative, not a hard guarantee.

Chapter 03

Capacity Estimation

Sizing a task scheduler requires understanding job ingestion rate, job storage size, polling frequency, and worker throughput. Every number below is an illustrative assumption to reason with, not a universal threshold that determines which architecture is "correct."

Job storage

As one illustrative scenario: 10 million active scheduled jobs, each record roughly 8 bytes (job_id) + 8 bytes (scheduled_at) + 8 bytes (status) plus metadata — call it a few hundred bytes — if the due-time index stores only job_id + scheduling metadata rather than the full task payload. The larger payload (which can be kilobytes) belongs in the durable job store or an object store, referenced by id, not duplicated into every scheduling index entry or Redis member — storing full payloads in the due-time index is one of the most common ways to make that index unnecessarily expensive to hold in memory or scan.

Throughput

Illustrative numbers to reason with, not fixed boundaries: a moderate schedule-creation rate (well under the write capacity of a single primary database), a peak due-job rate that can spike far above the steady-state average (the classic '9 AM burst' where many independently-created schedules resolve to the same nominal time), and a retry amplification factor (what fraction of attempts fail and re-enter the pipeline) that multiplies effective load beyond the raw schedule-creation rate.

Polling and dispatch cost

Naive polling — SELECT * FROM jobs WHERE scheduled_at <= NOW() AND status = 'pending' on every tick — is a full table scan without the right composite index. With an index on (status, scheduled_at), a bounded-batch query (LIMIT 500) is inexpensive under normal load, but a burst where a large fraction of the table becomes due at once still stresses the index and I/O regardless of indexing quality — this is a genuine scaling consideration, not something indexing alone solves.

Worker sizing and burstiness

Worker capacity should be sized against the peak due-job rate, not the average — a workload with high burstiness (many jobs due in the same second) needs either enough standing worker capacity to drain that burst within the scheduling-lag SLO, or a deliberate, bounded, rate-limited dispatch strategy that spreads the burst over a longer window at the cost of some added lateness. Which trade-off is right depends entirely on the product's tolerance for lateness — there's no universal 'right' number of workers independent of that tolerance.

Chapter 04

Naïve Solution: Single Cron Job

The simplest possible task scheduler: a cron daemon polls a database table every minute. When it finds jobs past their execution time, it runs them.

How it works

1. Application inserts a row into a scheduled_jobs table with a future run_at timestamp. 2. A cron job runs every minute: SELECT * FROM scheduled_jobs WHERE run_at <= NOW() AND status = 'pending'. 3. For each job, the cron process executes the task handler function inline. 4. It updates the job status to 'completed' or 'failed'.

When this works

A single-server cron approach works fine for: fewer than roughly 10,000 total scheduled jobs (a rough, illustrative threshold), job execution times under the cron interval (1 minute), single server deployments, low-stakes jobs where occasional missed executions are acceptable.

When it breaks

At scale, five failure modes emerge simultaneously.

Single point of failure

If the cron server crashes or reboots, no job is dispatched while it is down — there is no other node to pick them up. Because this design scans a durable table, overdue rows are picked up after restart (late, and possibly as a burst), so the risk is delay and a single point of dispatch rather than silent loss.

Table scan becomes expensive

SELECT with status='pending' on a 10M row table, every second, with high write contention is a recipe for lock waits and query timeouts.

Duplicate execution on restart

If the cron process is killed mid-execution, the job may be in 'running' state but will never complete. On next poll, it's stuck — or worse, re-executed.

No horizontal scaling

Running the same cron on two servers causes every job to be executed twice — there is no coordination.

1-minute minimum granularity

Standard cron cannot schedule jobs with sub-minute precision. A job scheduled for 9:00:30 runs at 9:01:00 at the earliest.

Chapter 05

Approach 1: Database Polling with SKIP LOCKED

The first real improvement: multiple workers claiming rows from the database, using PostgreSQL's SKIP LOCKED to prevent duplicate claims without a separate coordination service.

The key insight: pessimistic row-level locking

PostgreSQL supports SELECT ... FOR UPDATE SKIP LOCKED. When multiple workers execute this query concurrently, each one atomically claims a different set of rows; workers that encounter an already-locked row skip it instead of blocking. Within the claiming transaction itself, no two workers pick the same job.

sqlClaim quickly, commit, then execute outside the transaction
-- Short claiming transaction: find, lock, mark, commit
BEGIN;

SELECT id, payload FROM scheduled_jobs
WHERE run_at <= NOW() AND status = 'pending'
ORDER BY run_at
LIMIT 500
FOR UPDATE SKIP LOCKED;

UPDATE scheduled_jobs
SET status = 'claimed', lease_owner = $worker_id, lease_until = NOW() + interval '30 seconds'
WHERE id = ANY($claimed_ids);

COMMIT;

-- Execution happens *outside* this transaction, after it has committed
-- and released its locks -- never hold the transaction open while running
-- arbitrary business logic.

Why it works, and what it doesn't guarantee

No separate lock manager, no ZooKeeper, no Redis is required — the database transaction itself is the coordination mechanism during the claim. Workers can be added or removed freely, and SKIP LOCKED prevents two workers from claiming the same locked row during that claim transaction. It does not, on its own, provide exactly-once execution after a worker crash: a worker that claims a row and then dies mid-execution has still claimed it, and recovering that abandoned claim (via lease expiry) can hand it to a second worker — which is why idempotent execution and lease-based recovery, not the claim step alone, are what make this safe across retries.

Retry and lease recovery

A job in 'claimed' or 'running' state that never completes (worker crash) needs recovery: any row with a status of 'claimed'/'running' and a lease_until in the past is considered abandoned. A background reaper resets it to 'pending' so it can be claimed again. This requires the lease/heartbeat columns shown above, not just a bare status flag.

When Approach 1 fails

Hot table contention

At very high concurrent-claimer counts, lock contention on the index and row locks degrades performance — the exact point where this matters depends on hardware and index design, not a fixed worker count.

Polling overhead

Workers with no available work still issue claim queries on every poll cycle — at high poller counts, that's a meaningful steady load of empty queries against the primary.

Thundering herd is dampened, not solved

When a large batch of jobs becomes due simultaneously, SKIP LOCKED prevents duplicate claims, but every claimer still contends for the same due-time index range at the same moment — the index hot-spot itself remains a bottleneck that this pattern alone doesn't remove.

Chapter 06

Approach 2: Redis Sorted Sets

Replace the database polling loop with a Redis Sorted Set — using job execution timestamps as scores. Efficient range queries replace expensive table scans, at the cost of taking on a second stateful system.

The data structure

A Redis Sorted Set is a collection where every member has a score; members are retrievable in score order. ZRANGEBYSCORE returns all members within a score range in typically O(log N + M) time — well suited to 'give me all jobs due before NOW()'.

text
ZADD scheduled_jobs <unix_timestamp> <job_id>
ZRANGEBYSCORE scheduled_jobs 0 <now_timestamp> LIMIT 0 500
ZREM scheduled_jobs <job_id>  -- atomic claim

Atomic claim pattern

The claim-and-remove must be atomic to avoid two pollers claiming the same entry. A Lua script (executed atomically by Redis) can: find due jobs via ZRANGEBYSCORE, remove the claimed jobs from the pending set, and add them to a 'running' set with a lease expiry. Concurrent pollers each get disjoint sets of jobs.

Performance characteristics — stated carefully

Insert/update is typically O(log N); range query is typically O(log N + M). At large N this remains logarithmic, not constant — a set of 10 million entries costs roughly log2(10M) ~ 23 comparisons per operation, which is small in absolute terms but is still a logarithmic bound, not 'effectively constant time.' Avoid stating fixed throughput numbers (e.g. '1M+ ops/sec' or '100,000 jobs/sec') as guarantees — actual throughput depends heavily on payload size, network round-trip, and persistence configuration.

What this pattern is, and isn't

A Redis sorted set is one common delayed-scheduling primitive, not universally 'the' industry-standard solution. Several job libraries across ecosystems use a similar approach; it is a widely used, well-understood pattern for exactly this kind of due-time indexing.

Durability depends on configuration — and on what Redis is being asked to be

Redis persistence and failover behavior depend entirely on configuration (RDB snapshots, AOF, appendfsync policy, replica topology) — appendfsync everysec bounds typical loss to roughly the last second of writes under normal conditions, but that is not a universal guarantee under every failure topology. This matters differently depending on Redis's role: if PostgreSQL is the source of truth and Redis is only a due-time index, Redis needs to be treated as rebuildable and the system needs a reconciliation path so a missing Redis entry never permanently loses a durably-recorded job — see Durable Store + Redis Index Consistency for the full mechanics. If Redis itself is authoritative for job existence, the durability requirements on it become much stronger.

When Approach 2 fails

Redis is a SPOF unless clustered

A single Redis instance failing loses all pending job state unless it's the index for a separately durable store. Sentinel or Cluster mitigates node failure but adds operational complexity.

Memory bounded

All active entries must fit in Redis memory — keeping the index lightweight (metadata only, not full payloads) is what keeps this bounded predictably.

Cross-node coordination still required

Multiple scheduler nodes polling the same Redis structure still need the atomic claim logic above — Redis alone doesn't coordinate multiple scheduler instances any more than the database did in Approach 1.

Chapter 07

Approach 3: Distributed Scheduler with Leader Election

For fault tolerance with a single coordinated dispatcher, run multiple scheduler nodes with leader election: only the leader dispatches jobs, and followers watch for leader failure and take over. This is one valid coordination model, not the only way to scale a scheduler — competing DB claimers (Approach 1), sharded ownership, time-bucket ownership, and broker-native delayed delivery are all alternatives with different trade-offs, covered in Trade-offs & Decisions.

Leader election mechanism — done correctly

luaThe renewal must verify ownership before extending
-- Acquire: a unique owner token, not just a boolean flag
SET scheduler:leader my-unique-token NX PX 10000

-- Renew: only if we still hold it (atomic check via Lua)
if redis.call("get", KEYS[1]) == ARGV[1] then
  return redis.call("pexpire", KEYS[1], ARGV[2])
else
  return 0
end

A bare EXPIRE scheduler:leader ... refresh with no ownership check is unsafe: if the lease already expired and a different node acquired it, the original leader's blind EXPIRE call extends the new leader's lease using the old leader's schedule — both nodes can end up believing they're leader. Every renewal (and every release) must atomically verify current value == my token before acting, exactly as shown above.

Leader election alone does not prevent split-brain

A lease TTL bounds how long a stale leader can go undetected — it does not prevent that stale leader from acting during the window before it notices its lease expired. The failure sequence: the leader pauses (GC stall, CPU starvation, network partition) longer than the TTL; the lease expires; a follower acquires it and becomes leader; the original leader resumes and, unaware anything happened, continues believing it's still leader; without a further safeguard, both nodes can act as leader simultaneously.

Fencing closes the gap

A fencing/epoch token — a monotonically increasing number issued on every leadership change — tags every downstream write the leader makes. Downstream systems reject any write tagged with an epoch older than the highest one they've already seen. This, combined with idempotent dispatch on the receiving end, makes a stale leader's stray actions harmless duplicates rather than silent corruption, even in the rare window where two nodes briefly both believe they're leader.

Why one leader for dispatch (when you choose this model)

Dispatching from a single leader eliminates the race of multiple nodes claiming the same job at once. Workers remain fully distributed — the leader only decides what to enqueue; workers execute independently.

Sharded leadership

At very high scale, a single leader is a throughput bottleneck. Solution: shard by job type, customer ID, or hash range. Each shard has its own leader. Illustratively, if each leader sustained 10,000 jobs/sec (a workload-dependent assumption, not a benchmark), 10 shards would give 100,000 jobs/sec total without any single leader becoming hot.

Worker pool

Workers are stateless and horizontally scalable. They pull jobs from a queue (Kafka, SQS, Redis Queue). Each worker maintains a heartbeat. The scheduler monitors heartbeats and re-enqueues jobs from workers that stop heartbeating.

Retry and DLQ

Failed jobs enter a retry queue with exponential backoff: 1s, 2s, 4s, 8s, ... up to a maximum delay. After N failures (e.g., 5), the job moves to the Dead Letter Queue. DLQ jobs are never automatically retried — they require manual intervention or a separate recovery process.

Chapter 08

Final Production Architecture

The production-grade distributed task scheduler combines all three approaches into a layered architecture: a durable job store, a fast dispatch layer, horizontally scaled workers, and full observability.

ClientsJob creatorsAPI LayerREST / gRPCJob StorePostgreSQLSchedulerLeader electedJob QueueKafka / SQSWorkersHorizontally scaledDLQDead Letter QueueMonitorMetrics / AlertswritereaddispatchexecuteDLQ
Production architecture: clients submit jobs through the API layer into the durable Job Store; the leader-elected Scheduler scans due jobs and dispatches them onto a job queue for horizontally scaled workers, with a dead-letter queue and monitoring on the side.

Component breakdown

API Layer

REST/gRPC API for job creation, cancellation, and status queries. Validates payloads, generates idempotency keys, writes to the Job Store. Stateless and horizontally scaled.

Job Store (PostgreSQL)

Source of truth for all job state. Indexed on (scheduled_at, status). Supports point-in-time recovery. Read replicas serve status queries without hitting the primary.

Scheduler Service (Leader-elected)

Continuously scans the Job Store for jobs due in the next scheduling window. Dispatches ready jobs to the Job Queue. Manages retry scheduling. Only the leader dispatches to prevent duplicates.

Job Queue (Kafka or SQS)

Decouples dispatch from execution. The specific guarantees depend on which queue is chosen: Kafka gives ordered, replayable partitions and consumer-group-based parallelism, but expects the consumer to build its own redelivery/retry semantics. SQS gives native visibility-timeout redelivery and DLQ redrive, without replay or strict ordering across the whole queue. Neither should be assumed to provide the other's guarantees by default — see Queue Semantics in the Architecture Deep Dive for the full comparison. The write from the Scheduler Service into this queue is not atomic with its database state change — see the durable dispatch / transactional outbox mechanism in the Architecture Deep Dive for how that gap is closed.

Worker Pool

Stateless execution nodes. Pull jobs from the queue, acquire an idempotency lock, execute the task handler, acknowledge success or report failure.

Retry Queue + DLQ

Failed jobs enter a delay queue with exponential backoff. After max retries, moved to DLQ. DLQ triggers alerting and requires human review.

Monitoring & Alerting

Track: queue depth, execution latency p50/p95/p99, retry rate, DLQ size, worker heartbeats, scheduler leader lease status. Alert on: DLQ growth, queue depth spike, missed executions.

This is the architecture the rest of this page builds toward — the Architecture Deep Dive covers each of these components' internal mechanics (due-time indexing, durable dispatch, leader fencing, worker leases, idempotency) in far more depth.

Chapter 09

Job Lifecycle

A production job lifecycle needs to separate two things the naive model conflates: the logical job (or schedule)'s state, and the state of each individual execution attempt against it. A job can survive several failed attempts without the job itself ever being 'failed.'

SCHEDULEDQUEUEDRUNNINGSUCCEEDEDFAILEDRETRYINGDEADduepulledokerrretrybackoffmax retries
Job lifecycle state machine: SCHEDULED -> QUEUED -> RUNNING, then SUCCEEDED or FAILED -> RETRYING with backoff, eventually DEAD after max retries are exhausted.

Logical job / schedule states

SCHEDULED (eligible to fire, or waiting for its next occurrence), CANCELLED, COMPLETED, PAUSED (recurring schedules only), and DEAD/TERMINAL_FAILED (retries exhausted or a permanent failure). These describe the job's overall lifecycle, not any single execution attempt.

Execution attempt states

READY/DISPATCH_PENDING (claimed, not yet enqueued), QUEUED (published to the dispatch queue), LEASED/RUNNING (a worker holds the lease and is executing), SUCCEEDED, FAILED_RETRYABLE, FAILED_PERMANENT, and TIMED_OUT. Every attempt has its own row and its own history — attempt 3 failing doesn't erase the record of attempts 1 and 2.

The orphaned attempt problem, precisely

A worker whose process crashes cannot mark its own attempt FAILED — it's gone. What actually happens: the worker publishes a heartbeat periodically while it holds the lease; if heartbeats stop, a separate reaper component observes that the lease has expired (not that the worker "failed" — the reaper has no idea what happened to the worker) and reclaims the attempt, creating or permitting a new attempt rather than mutating the dead one. This is a meaningful distinction: the system is inferring failure from absence, not receiving an authoritative failure signal, and everything downstream (idempotency, dedup) has to account for that uncertainty.

Durable job creation and idempotent requests

A client that creates a job can time out after the database commits but before the response arrives, and reasonably retry the creation request. Without an idempotency key, that retry creates a second logical job for the same intended work. The fix is the same one used broadly for idempotent APIs: require a stable client-supplied request/idempotency key on job creation, so one logical scheduling request — retried any number of times — results in exactly one logical job or schedule record.

The dual-write problem: job store to queue

Marking a job SCHEDULED -> QUEUED in the database and publishing to the dispatch queue (Kafka/SQS) are two separate operations against two separate systems — there is no way to make them atomic by default. If the DB commit succeeds and the process crashes before publishing, the job is durably marked QUEUED but no message was ever sent — it's stranded unless something notices. If the queue publish succeeds and the process crashes before the DB records the dispatch, a retry of the whole operation can publish a second message for the same logical dispatch.

The baseline fix is the transactional outbox pattern: in one database transaction, claim the due job, write a durable outbox/dispatch record, and update the attempt state — all three commit together or not at all. A separate relay process reads unpublished outbox rows, publishes them to the queue, and marks them published. The relay's own publish step can still be repeated (a crash between publish and mark-published), which is exactly why consumers must remain idempotent regardless — the outbox closes the silent-stranding gap, it does not manufacture atomicity across the DB and an arbitrary external queue. Calling this combination "atomic" requires naming the mechanism that makes it safe.

Worker lease semantics

A worker lease means: this worker currently owns the right to make progress on this attempt. It does not mean no duplicate external side effect can occur — those are different guarantees, and conflating them is a common design mistake. A lease record needs a unique attempt identity, a lease owner, a lease expiry, a generation/fencing token (so a stale lease-holder's late actions can be detected and rejected), and periodic heartbeat renewal that atomically checks current ownership before extending.

Avoid the common but imprecise advice that lease TTL should always be "significantly longer than worst-case execution time" — for long-running or unbounded-duration jobs, that's impractical (see Long-Running Jobs in the Deep Dive). The better pattern: keep the lease comfortably longer than the heartbeat interval and expected transient pauses, renew it periodically while work continues, detect failure based on missed renewals rather than total elapsed time, and apply a separate maximum execution timeout only where the product genuinely needs one.

Worker idempotency, precisely

"Check before acting" is not, by itself, safe advice — a check and the subsequent act can race (two workers both check, both see "not yet done," both act), and it offers no protection for side effects against arbitrary external systems regardless of how carefully it's implemented locally. What actually works depends on where the side effect lives: for a local database mutation, use a unique constraint, a transaction, and an atomic upsert or conditional update — the database itself enforces the invariant, not a race-prone check. For an external API that supports an idempotency key, pass one and let the provider deduplicate. For an external API with no idempotency support, the honest options are durable operation state plus reconciliation, business-specific deduplication, or accepting that an exactly-once effect may not be fully achievable — a scheduler-level lock (Redis or otherwise) is not a universal exactly-once mechanism for effects outside the scheduler's own storage.

Cancellation semantics, precisely

"Running jobs cannot be cancelled mid-flight" oversimplifies three genuinely different cases. A pending/scheduled job (not yet dispatched) can usually be cancelled strongly and immediately. A queued-but-not-yet-started job is race-prone: the cancellation and the worker picking up the message can interleave, so the worker must re-check the job's current state and version before executing, and discard a stale message for an already-cancelled job. A running execution may support cooperative cancellation — the worker periodically checks whether it's been asked to stop at safe points — but cannot be forcibly halted mid-instruction without risking a corrupted partial side effect, and any external side effects already performed cannot necessarily be undone. Rescheduling follows the same version-check discipline: bump the job's version, invalidate the old due-index entry, add a new one at the new time, and let the version check reject any stale in-flight dispatch against the old time.

Chapter 10

Scheduling Strategies

There are several real mechanisms for implementing "run job X at time T," and none of them is universally best — each trades precision, persistence, complexity, and recovery behavior differently, and the right choice depends on scale and the specific correctness requirements of the workload.

ApproachPrecisionPersistenceLong-delay storageRecovery after crash
DB indexed pollingBounded by poll intervalDurable by defaultNative — just a future rowAutomatic; state was never lost
SKIP LOCKED competing claimersBounded by poll intervalDurable by defaultNativeAutomatic; needs lease-recovery reaper
Redis sorted set / delay indexTighter, low query costNot durable alone — needs a durable backing storeFeasible but memory-boundedRequires rebuild from durable store
Hierarchical timing wheelFine-grained, event-drivenNot durable — in-memory onlyAwkward — bounded by wheel depth/memoryRequires full rebuild from durable store
Broker-native delayed delivery (SQS/RabbitMQ)Good for short delaysDurable within the brokerOften capped (e.g. SQS ~15 min)Handled by the broker, but no query/cancel once queued
Time-bucket / sharded schedulerCoarser (bucket granularity) but scales horizontallyDurable if buckets back onto durable storageNative, partitioned across shardsPer-shard recovery; needs shard-ownership handling
No single approach wins on every dimension

Cancellation and update behavior differ sharply too: a database row can simply be updated or deleted; a Redis sorted-set member can be removed or re-scored in O(log N); a timing wheel entry may require locating its current slot; and a message already inside a broker's native delay queue generally cannot be updated or cancelled at all once enqueued. Burst handling also differs: DB/Redis/time-bucket approaches can apply bounded-batch retrieval and rate-limited dispatch to smooth a spike; a timing wheel fires everything scheduled for the same tick essentially at once, which needs an explicit dispatch-rate limiter downstream of it.

Recurring schedules: definition vs. individual occurrence

A recurring schedule ("every day at 9am") is not itself a job — it's a template that generates a stream of individual occurrences over time. weekly-report schedule -> execution for 2026-09-21 -> execution for 2026-09-28, and so on. Conflating the schedule with any one occurrence is how systems end up either duplicating or silently dropping runs across restarts and failovers.

Each occurrence needs a deterministic identity, typically occurrence_id = f(schedule_id, nominal_fire_time), so that a scheduler retry, a failover, or a recovery scan that re-evaluates the schedule produces the same occurrence identity rather than a new logical duplicate — inserting with that deterministic id under an idempotent constraint turns a repeated generation attempt into a harmless no-op. A schedule record needs, conceptually: the recurrence rule itself (cron expression or interval), an explicit IANA timezone, a precomputed next-fire-time, a misfire policy, an overlap policy, a version (for safe concurrent updates), and a paused flag.

Time zones and DST

For a schedule like "every day at 09:00 Europe/London," the system must store the recurrence rule and the IANA timezone together, and compute each occurrence's absolute instant fresh, according to that timezone's rules, at generation time — not convert the wall-clock time to a fixed UTC offset once at creation and reuse it forever, because the UTC offset for 9am London changes across the DST boundary. Two DST edge cases need an explicit product policy: the "spring forward" transition can make a local time like 2:30am not exist at all on that day, and the "fall back" transition makes a local time like 1:30am occur twice — the schedule needs to define which occurrence it means, rather than leaving it to whatever a naive time library happens to resolve. For one-time absolute jobs (not recurring), storing a normalized instant such as UTC from the start avoids this class of problem entirely.

Misfire and catch-up policy

When the scheduler was down and recovers, recurring schedules may have missed one or more occurrences. This needs an explicit, per-schedule policy rather than one system-wide default: run every missed occurrence (dangerous unless occurrences are cheap and fully idempotent), run only the most recent missed occurrence, skip all missed occurrences and resume from the next future one, or coalesce missed work into a single catch-up execution. Automatically dispatching every missed recurrence after a long outage is rarely the right default — it can recreate a self-inflicted thundering herd, and for some job types (a daily reminder run twice) it's simply wrong.

Overlap and concurrency policy

A separate and distinct problem from duplicate execution: recurring execution N may still be running when occurrence N+1 becomes due. The available policies — allow overlap (fine for cheap, independent occurrences; dangerous for anything sharing state or resources), skip the new occurrence, queue the next occurrence to start once the current one finishes, replace/cancel the prior attempt where that's safely supported, or serialize execution per schedule key — again need to be chosen per schedule based on what the occurrences actually do, not applied uniformly.

Chapter 11

Retry Handling & Dead Letter Queues

Retry logic is where most task scheduler implementations have subtle bugs. Getting it right requires classifying why a job failed, not just counting how many times it's been tried.

Exponential backoff with jitter, capped

Retry delay formula: base_delay * 2^attempt + random_jitter, capped at a maximum delay so backoff doesn't grow unbounded into days or weeks. Without jitter, every job that failed at the same instant retries at the same instant again — trading one thundering herd for a synchronized retry storm.

Classify the failure before deciding how to retry

Transient (timeout, connection reset) — retry with standard backoff. Throttling (an explicit rate-limit or 429 response) — retry, but honor any Retry-After guidance and back off more aggressively than the default curve. Permanent (validation error, business-rule rejection, auth failure) — do not retry; route straight to the DLQ, since retrying an inherently invalid request wastes attempts and delays visibility into an actual bug. Unknown/ambiguous (a timeout with no confirmation the side effect didn't happen) — retry only because the operation is idempotent, not because the failure looked safe.

What the retry engine needs to track, beyond a counter

Attempt count, next_retry_at, last error class, first-failure time (for enforcing an overall deadline independent of attempt count), an explicit deadline past which no further retry happens regardless of remaining budget, and the retry policy version that applied — so a later policy change doesn't reinterpret already-recorded history incorrectly.

Retry budgets and dependency-wide protection

A per-job retry limit isn't the only safeguard worth having: a retry budget (a cap on total retry volume across a time window, independent of any single job) and a circuit breaker or rate limiter in front of a failing downstream dependency prevent a single struggling dependency from being hammered by every affected job's independent retry schedule simultaneously — which can turn a partial outage into a complete one.

Dead Letter Queue and safe replay

A DLQ preserves the original job payload, all failure reasons, and full attempt history for jobs that exhausted retries or failed permanently. It must trigger an alert as the DLQ grows — illustrative thresholds like "more than 10 jobs/min" or "more than 1,000 jobs" are examples to calibrate for your own workload, not universal numbers. Replay needs its own safety rules: a replay creates a new attempt while preserving the original logical job identity (so the full history stays linked); bulk replay must be rate-limited, for exactly the same reason initial dispatch is — to avoid a self-inflicted burst; before replaying, verify the original root cause is actually fixed, not just that time has passed; attempt and error history from before the replay must be preserved, not overwritten; and operator replay/discard actions need authorization and an audit log, since they're production actions with real consequences, especially for anything touching money or user-facing state.

Chapter 12

Scaling Strategy

Task schedulers scale in stages, but the trigger for moving to the next stage should be a measured bottleneck, not a fixed jobs-per-day threshold — two teams at the same job volume can have very different bottlenecks depending on job size, burstiness, and downstream capacity.

Stage A — DB-native scheduler

A single database-backed scheduler (indexed polling or SKIP LOCKED competing claimers) with no separate queue. Appropriate while database load and scheduling-lag SLO are comfortable — this covers a wide range of real workloads without needing any additional infrastructure.

Stage B — DB claiming + worker queue

Add a message queue between claiming and execution so worker throughput and backpressure can scale independently of the database. Move here when execution needs to scale independently of how due-job discovery is done, not at a fixed volume.

Stage C — Dedicated due-time index / partitioned scheduler

Introduce a Redis sorted set, timing wheel, or partitioned due-time structure once due-job discovery itself — not execution — is the measured bottleneck (e.g. the claiming query is the slowest part of the pipeline under load).

Stage D — Sharded scheduling ownership

Partition due-time ownership across multiple leader-elected (or competing-claimer) shards once a single dispatcher or partition can no longer meet the dispatch SLO on its own, even with a dedicated due-time index.

Stage E — Multi-region

Extend to multiple regions only when availability, latency, or data-residency requirements genuinely justify the added complexity of cross-region fencing and failover — not as a default "final" stage every scheduler eventually needs.

What actually triggers moving to the next stage

Measure, don't guess: query load and lock contention on the claiming query, dispatch lag against the SLO, queue depth and oldest-message age, due-bucket size at peak, worker utilization, downstream throttling signals, and (once sharded) per-shard/leader saturation. A team that never sees these metrics approach their limits has no reason to move to the next stage, regardless of raw job volume.

Chapter 13

Failure Scenarios

Understanding how each component fails — and how the system recovers — separates a reliable scheduler from a fragile one. See Failure & Scale Scenarios for a much deeper treatment of these and other failure modes.

Worker crash

Attempt in RUNNING state, worker dies. Recovery: heartbeat monitor (reaper) detects the missing pulse once the lease expires, reclaims the attempt, and permits a new one via the retry path. Recovery latency is bounded by the lease/heartbeat configuration, not a fixed number — tune it against how quickly a real crash needs to be noticed versus how much heartbeat jitter is tolerable.

Scheduler crash (without election)

All dispatch stops. Jobs pile up in a due-but-not-dispatched state. Recovery requires a manual restart plus a catch-up scan for now-overdue jobs, ideally released through the same bounded, rate-limited path as any other burst rather than all at once. Recovery time: human response time.

Scheduler crash (with leader election + fencing)

A follower detects the leader's absence once the lease expires and acquires it, fenced by an incrementing epoch so a resumed stale leader's actions can't silently corrupt state. Recovery time is bounded by the lease TTL, at the cost of a real (if narrow) window where fencing — not the TTL alone — is what keeps a stale leader's actions safe.

Database primary failure

Job writes and status updates fail. Workers can drain jobs already dispatched before the outage but cannot update status. Recovery: promote a replica; under asynchronous replication, the last few seconds of committed writes may be lost, and any jobs created in that window may need to be recreated or reconciled.

Thundering herd at a common due time

A large fraction of scheduled jobs share a nominal due time (many independently-created 9 AM schedules resolving to the same instant, for example). This does not require executing everything within seconds of that instant — the product defines acceptable lateness, and the mitigation (bounded batches, rate-limited dispatch, partitioned due-time discovery, and jitter only where job semantics allow it) is what actually prevents the burst from overwhelming the database, queue, and downstream dependencies simultaneously.

Chapter 14

Key Tradeoffs

Every architectural decision in a task scheduler involves an explicit trade-off. The four below are a quick-reference summary — the full set of fifteen, with deeper analysis of each, lives on the dedicated Trade-offs & Decisions page.

DecisionSummary
At-least-once vs exactly-once effectAt-least-once execution attempts plus idempotent/deduplicated side effects is the practical target for arbitrary external effects — true exactly-once is only approximable in specific, bounded domains, not achieved by 2PC as a default answer.
Polling vs pushNot one binary choice — discovery of due work is inherently a poll, dispatch to a queue is a push, and worker consumption from that queue is a pull. Most production systems use all three, at different stages.
Database vs queue as source of truth"Use both" is common and practical, but it introduces a dual-write consistency problem (the outbox pattern) rather than being automatically safe — a durable log/queue as sole source of truth is a valid alternative with its own trade-offs, not a lesser option.
Single leader vs competing claimers vs sharded ownershipThree distinct coordination models, not two — competing claimers (SKIP LOCKED) avoids election complexity entirely but grows contention with claimer count; single leader is simplest but throughput-capped; sharding scales linearly at the cost of rebalancing and fencing complexity.
Four of the fifteen decisions — see the full page for the rest