Requirements & scope
Problem Statement & Requirements
Design a distributed task scheduling system that reliably enqueues, schedules, and executes tasks (jobs) across a fleet of workers at massive scale — similar to Celery, Temporal, Apache Airflow, Sidekiq, or cloud-native solutions like AWS Step Functions and Google Cloud Tasks.
Functional Requirements
- One-time tasks — submit a task for immediate execution (e.g., "send this email now")
- Delayed tasks — schedule a task for a specific future time (e.g., "send reminder in 24 hours")
- Recurring / Cron tasks — periodic execution on a cron schedule (e.g., "run report every Monday 9am")
- Task priorities — high, medium, low; high-priority tasks execute before low-priority ones
- Task dependencies (DAG) — task B runs only after task A succeeds (workflow/pipeline orchestration)
- Retry with backoff — configurable retry count, exponential backoff, dead-letter queue for exhausted retries
- Task status tracking — query status: PENDING, SCHEDULED, RUNNING, SUCCEEDED, FAILED, RETRYING, DEAD
- Cancellation — cancel a pending or scheduled task before it starts
- Rate limiting — limit execution rate per task type (e.g., max 100 emails/sec)
- Timeout enforcement — kill tasks that exceed max_execution_time
- Result storage — persist task output for callers to retrieve
- Idempotency — support idempotency keys to prevent duplicate execution
Non-Functional Requirements
- Exactly-once execution — each task must execute exactly once (or at-least-once with idempotency)
- Low scheduling latency — task picked up by worker within < 100 ms of its scheduled time
- High throughput — schedule and execute millions of tasks per day
- High availability — survive node failures with zero task loss
- Horizontal scalability — add workers to increase execution throughput linearly
- Durability — submitted tasks must not be lost (persisted before ACK)
- Fairness — one tenant's burst of tasks must not starve other tenants
- Observability — metrics, logs, distributed tracing per task execution
Out of Scope
- Stream processing (Kafka Streams / Flink — different paradigm)
- Full workflow DSL (Temporal's detailed workflow-as-code semantics)
- Map-reduce / batch analytics (Spark / Hadoop territory)
Scale estimations
Scale Estimations
Traffic
| Metric | Value |
|---|---|
| Tasks submitted / second | 50K TPS |
| Tasks executed / second | 50K TPS (steady state, submitted ≈ executed) |
| Peak submission rate | 150K TPS (3x burst) |
| Delayed tasks (waiting) at any time | 500M |
| Recurring cron jobs (active definitions) | 10M |
| DAG workflows triggered / day | 5M |
| Average tasks per DAG | 8 |
Storage
| Metric | Value |
|---|---|
| Average task payload | 2 KB (serialized arguments + metadata) |
| Average task result | 1 KB |
| Task record (full row) | ~5 KB (payload + metadata + status + timestamps) |
| Tasks per day | 50K/s × 86,400 = ~4.3 billion/day |
| Retention period | 30 days |
| Total task records | 4.3B × 30 = ~130 billion records |
| Storage (task records) | 130B × 5 KB = ~650 TB |
| Delayed task index | 500M × 100 bytes = ~50 GB (fits in memory) |
| Cron definitions | 10M × 500 bytes = ~5 GB |
Bandwidth
| Metric | Value |
|---|---|
| Task submission throughput | 50K/s × 2 KB = ~100 MB/s |
| Task dispatch throughput | 50K/s × 2 KB = ~100 MB/s |
| Result storage throughput | 50K/s × 1 KB = ~50 MB/s |
| Total cluster bandwidth | ~250 MB/s |
Hardware Estimate
| Component | Spec |
|---|---|
| Scheduler nodes | 5-10 (active-active, stateless scheduling logic) |
| Task store (DB) | 20-50 nodes (sharded, SSD-backed) |
| Worker nodes | 1,000-5,000 (depending on avg task duration) |
| Delayed task queue | 3-5 nodes (Redis or custom priority queue, in-memory) |
| Message broker (internal) | 5-10 nodes (Kafka or internal queue) |
Layered architecture
High-Level Architecture
High-Level ArchitectureExcalidraw diagram · editable shapes · reveal step by stepExplore
Component Breakdown
Component BreakdownExcalidraw diagram · editable shapes · reveal step by stepExplore
API & contracts
API Design
Submit Task
POST /api/v1/tasks
Content-Type: application/json
Request:
{
"task_type": "send_email",
"payload": {
"to": "user@example.com",
"subject": "Welcome!",
"template_id": "onboarding-v2"
},
"priority": "high", // high | medium | low
"scheduled_at": "2026-04-04T09:00:00Z", // null = immediate
"max_retries": 3,
"retry_backoff": "exponential", // exponential | fixed | linear
"retry_delay_seconds": 60, // base delay between retries
"timeout_seconds": 300, // kill if running > 5 min
"idempotency_key": "welcome-user-123", // prevent duplicate submission
"queue": "email-queue", // logical queue for routing
"metadata": {
"tenant_id": "acme-corp",
"correlation_id": "req-abc"
}
}
Response (202 Accepted):
{
"task_id": "task-7f3a9b2c",
"status": "SCHEDULED",
"scheduled_at": "2026-04-04T09:00:00Z",
"created_at": "2026-04-03T10:00:00Z"
}Create Recurring (Cron) Task
POST /api/v1/cron-tasks
Content-Type: application/json
Request:
{
"name": "daily-report-generation",
"cron_expression": "0 9 * * 1-5", // 9am weekdays
"timezone": "America/New_York",
"task_type": "generate_report",
"payload": {
"report_type": "daily_sales",
"recipients": ["team@acme.com"]
},
"max_retries": 2,
"timeout_seconds": 600,
"overlap_policy": "skip", // skip | queue | cancel_running
"enabled": true
}
Response (201 Created):
{
"cron_id": "cron-8a4b1c",
"name": "daily-report-generation",
"next_fire_time": "2026-04-04T09:00:00Z",
"status": "ACTIVE"
}Submit DAG Workflow
POST /api/v1/workflows
Content-Type: application/json
Request:
{
"workflow_name": "user-onboarding",
"tasks": [
{
"task_id": "create-account",
"task_type": "create_account",
"payload": {"user": "alice@example.com"},
"dependencies": []
},
{
"task_id": "send-welcome-email",
"task_type": "send_email",
"payload": {"template": "welcome"},
"dependencies": ["create-account"]
},
{
"task_id": "setup-defaults",
"task_type": "setup_user_defaults",
"payload": {},
"dependencies": ["create-account"]
},
{
"task_id": "notify-sales",
"task_type": "notify_slack",
"payload": {"channel": "#new-users"},
"dependencies": ["send-welcome-email", "setup-defaults"]
}
]
}
Response (202 Accepted):
{
"workflow_id": "wf-c5d2e1",
"status": "RUNNING",
"tasks": {
"create-account": "PENDING",
"send-welcome-email": "BLOCKED",
"setup-defaults": "BLOCKED",
"notify-sales": "BLOCKED"
}
}Query Task Status
GET /api/v1/tasks/{task_id}
Response (200 OK):
{
"task_id": "task-7f3a9b2c",
"task_type": "send_email",
"status": "SUCCEEDED",
"priority": "high",
"created_at": "2026-04-03T10:00:00Z",
"scheduled_at": "2026-04-04T09:00:00Z",
"started_at": "2026-04-04T09:00:00.045Z",
"completed_at": "2026-04-04T09:00:01.230Z",
"duration_ms": 1185,
"attempts": 1,
"worker_id": "worker-42",
"result": {"message_id": "msg-xyz789"},
"metadata": {"tenant_id": "acme-corp"}
}Cancel Task
POST /api/v1/tasks/{task_id}/cancel
Response (200 OK):
{
"task_id": "task-7f3a9b2c",
"status": "CANCELLED",
"cancelled_at": "2026-04-03T12:00:00Z"
}
Behavior:
PENDING/SCHEDULED → immediately set to CANCELLED
RUNNING → send cancellation signal to worker;
worker should check cancellation flag periodically
SUCCEEDED/FAILED → 409 Conflict (already terminal)Data model
Data Model
Task Record
Task RecordExcalidraw diagram · editable shapes · reveal step by stepExplore
Cron Task Definition
Cron Task DefinitionExcalidraw diagram · editable shapes · reveal step by stepExplore
Workflow (DAG) Record
Workflow (DAG) RecordExcalidraw diagram · editable shapes · reveal step by stepExplore
Task State Machine
Task State MachineExcalidraw diagram · editable shapes · reveal step by stepExplore
Core design decisions
Core Design Decisions
Decision 1: How to Implement the Delayed Task Queue
The hardest problem: efficiently waking up tasks at the right time among 500 million waiting tasks.
Option A: Database Polling
Option A: Database PollingExcalidraw diagram · editable shapes · reveal step by stepExplore
| Pros | Cons |
|---|---|
| Simple, no extra infrastructure | Polling creates constant DB load |
| Database is the single source of truth | Latency: up to 1 second delay (poll interval) |
| Transactional guarantees (ACID) | Doesn't scale well beyond ~10K tasks/sec |
| Works with any SQL database | Index scan on 500M rows is expensive |
Option B: Redis Sorted Set (ZSET)
Option B: Redis Sorted Set (ZSET)Excalidraw diagram · editable shapes · reveal step by stepExplore
| Pros | Cons |
|---|---|
| O(log N + K) for due task lookup — very fast | Redis is in-memory — 50 GB dedicated to timers |
| Sub-second precision (100ms poll) | Need durability strategy (AOF + replication) |
| Handles 500M entries efficiently | Extra infrastructure to maintain |
| Atomic ZRANGEBYSCORE + ZREM | Must sync state between Redis and DB |
Option C: Hierarchical Timing Wheel
Option C: Hierarchical Timing WheelExcalidraw diagram · editable shapes · reveal step by stepExplore
| Pros | Cons |
|---|---|
| O(1) insert and O(1) fire per tick | Complex implementation |
| Memory-efficient | State lost on crash (need WAL or checkpointing) |
| No database polling overhead | Single-machine capacity limit |
| Used by Kafka, Netty, Linux kernel | Cascading between levels adds complexity |
Recommendation: Redis Sorted Set + Database Backup
Recommendation: Redis Sorted Set + Database BackupExcalidraw diagram · editable shapes · reveal step by stepExplore
Decision 2: Task Dispatch — Push vs Pull
Decision 2: Task Dispatch — Push vs PullExcalidraw diagram · editable shapes · reveal step by stepExplore
Decision 3: Exactly-Once Execution
Decision 3: Exactly-Once ExecutionExcalidraw diagram · editable shapes · reveal step by stepExplore
Request flows
Detailed Flow Diagrams
Immediate Task Execution Flow
Immediate Task Execution FlowExcalidraw diagram · editable shapes · reveal step by stepExplore
Delayed Task Flow
Delayed Task FlowExcalidraw diagram · editable shapes · reveal step by stepExplore
Cron Evaluation Flow
Cron Evaluation FlowExcalidraw diagram · editable shapes · reveal step by stepExplore
DAG Workflow Execution Flow
DAG Workflow Execution FlowExcalidraw diagram · editable shapes · reveal step by stepExplore
Performance & caching
Workshop note · added for the website’s common reading format
Start with the dominant access pattern of Distributed Task Scheduler. Worker fleet is one place to inspect capacity and tail latency. Measure before introducing a cache: define the cache key, invalidation policy, stale-data budget, and cold-start behavior.
Batch independent work where the latency budget allows it. Bound queues and concurrency, and verify that an optimization does not move the bottleneck to a dependency.
Advanced design
Priority Queue Implementation
Multi-Level Priority Queues
Multi-Level Priority QueuesExcalidraw diagram · editable shapes · reveal step by stepExplore
Multi-Tenant Fairness
Multi-Tenant FairnessExcalidraw diagram · editable shapes · reveal step by stepExplore
Edge cases
Handling Edge Cases
Clock Skew in Distributed Schedulers
Problem: Multiple scheduler nodes with slightly different clocks
Node A (clock +2s ahead): fires cron task at T-2s (too early)
Node B (clock -1s behind): doesn't see task as due yet
Solution:
1. NTP sync all nodes (< 100ms drift acceptable)
2. Cron evaluator uses single source of time (DB's NOW() function)
3. Delayed queue poller uses Redis TIME command (server-side clock)
4. Always compare against the same clock, not local system timeWorker Dies Mid-Execution
Problem: Worker starts processing "charge_credit_card"
→ charges the card → crashes before ACK
Scenario: Task re-enqueued → new worker charges card AGAIN
Solution: Lease + Idempotency (defense in depth)
Layer 1: Lease-based timeout
Worker must heartbeat every 90s (lease = 5 min)
No heartbeat in 5 min → task re-enqueued
→ Ensures no task is stuck forever
Layer 2: Idempotency at application level
charge_credit_card(idempotency_key="order-123-charge")
→ Stripe sees same idempotency key → returns original result
→ No double charge even if task runs twice
Layer 3: Checkpoint for long tasks
Long-running tasks (e.g., 30 min data pipeline):
→ Save progress to checkpoint store every N records
→ On retry, resume from last checkpoint
→ Avoids reprocessing entire datasetQueue Buildup / Backpressure
Problem: Producers submit 100K tasks/s, workers process 50K/s
→ Queue grows unboundedly → memory/disk exhaustion
Solution: Multi-layer backpressure
Layer 1: Per-tenant rate limiting at API gateway
→ Max 1,000 submissions/sec per tenant
→ Return 429 Too Many Requests
Layer 2: Queue depth alerting
→ Alert when queue depth > 100K
→ Auto-scale worker fleet (k8s HPA on queue depth metric)
Layer 3: Queue depth limit
→ Hard cap: reject new tasks when queue > 10M
→ Return 503 Service Unavailable with Retry-After header
Layer 4: Priority shedding
→ When overloaded, drop low-priority tasks first
→ Move to overflow queue (processed when load decreases)Cron Double-Fire Prevention
Problem: Two cron evaluator nodes both see "next_fire_time <= now"
→ Both create task instances → duplicate execution
Solution: Optimistic locking on cron record
Evaluator A:
UPDATE cron_tasks
SET last_fired_at = now(),
next_fire_time = compute_next(cron_expr)
WHERE cron_id = 'cron-8a4b1c'
AND next_fire_time = '2026-04-04T09:00:00Z' ← CAS guard
→ If another evaluator already updated next_fire_time,
this UPDATE affects 0 rows → skip (no duplicate)
→ Only one evaluator wins the CAS → exactly one task created
Combined with: FOR UPDATE SKIP LOCKED
→ Multiple evaluators process different cron jobs in parallel
→ Same cron job processed by exactly one evaluatorTradeoffs
Tradeoffs & Design Decisions Summary
| Decision | Option A | Option B | Chosen | Why |
|---|---|---|---|---|
| Delayed queue | DB polling | Redis sorted set | Redis ZSET + DB backup | O(log N) lookups, sub-second precision, DB as durable fallback |
| Dispatch model | Push (scheduler assigns) | Pull (worker requests) | Pull | Natural backpressure, self-regulating, no scheduler bottleneck |
| Exactly-once | Distributed transactions | At-least-once + idempotency | At-least-once + idempotency | Distributed txns too slow/complex; idempotency achieves same result |
| Priority | Single sorted queue | Multi-level separate queues | Multi-level with weighted fairness | Separate queues + BRPOP is simple; weighted prevents starvation |
| Task store | SQL (PostgreSQL) | NoSQL (Cassandra) | Sharded PostgreSQL | ACID for state transitions, FOR UPDATE SKIP LOCKED for safe polling |
| Cron evaluation | Distributed lock (one evaluator) | Parallel + optimistic CAS | Parallel + CAS | No single bottleneck; CAS prevents double-fire; horizontal scaling |
| Retry backoff | Fixed delay | Exponential + jitter | Exponential + full jitter | Prevents thundering herd on retries; jitter spreads load |
| Worker health | Heartbeat to coordinator | Lease-based timeout | Lease-based | Simpler; no coordinator needed; worker extends own lease; timeout = dead |
| Multi-tenant | Shared queue, FIFO | Per-tenant queues + round-robin | Per-tenant + weighted round-robin | Prevents noisy-neighbor starvation; weighted fairness by tier |
| Result storage | In task table (same row) | Separate result store | Same row (task table) | Simplicity; result is small (<1 KB); no extra infrastructure |
Reliability & fault tolerance
Retry & Error Handling
Retry Strategy
Retry StrategyExcalidraw diagram · editable shapes · reveal step by stepExplore
Dead Letter Queue
Dead Letter QueueExcalidraw diagram · editable shapes · reveal step by stepExplore
Classifying Errors
Classifying ErrorsExcalidraw diagram · editable shapes · reveal step by stepExplore
Reliability & Fault Tolerance
Single Points of Failure & Mitigations
Single Points of Failure & MitigationsExcalidraw diagram · editable shapes · reveal step by stepExplore
Failure Scenarios
Scenario 1: Worker crashes mid-task
→ Lease expires (5 min) → task re-enqueued → new worker picks up
→ Idempotency key prevents duplicate side effects
→ Worst case: 5 min delay (tunable via lease duration)
Scenario 2: Redis queue node fails
→ Redis Cluster failover to replica (~5-15s)
→ During failover: workers retry BRPOP → brief delay
→ If full Redis cluster down: schedulers fall back to DB polling
SELECT ... WHERE status='PENDING' FOR UPDATE SKIP LOCKED
→ Tasks not lost (DB is the source of truth)
Scenario 3: Scheduler node fails
→ Other scheduler nodes pick up its cron evaluations
→ FOR UPDATE SKIP LOCKED ensures no conflicts
→ Zero impact on already-queued tasks
Scenario 4: Database primary fails
→ Automatic failover to standby (30-60s typically)
→ During failover: task submissions return 503, retry with backoff
→ Workers continue executing already-dispatched tasks
→ Tasks in Redis queue continue processing
Scenario 5: Network partition (split brain)
→ Redis min-replicas-to-write prevents split-brain writes
→ DB standby promotion only if primary unreachable by quorum
→ Workers on isolated side: leases expire, tasks re-enqueuedData Recovery
Data RecoveryExcalidraw diagram · editable shapes · reveal step by stepExplore
Production architecture
Full System Architecture (Production-Grade)
Full System Architecture (Production-Grade)Excalidraw diagram · editable shapes · reveal step by stepExplore
Further exploration
Comparison: Task Scheduler vs Related Systems
Comparison: Task Scheduler vs Related SystemsExcalidraw diagram · editable shapes · reveal step by stepExplore
Interview playbook
Interview Tips
Start with the task lifecycle state machine — Draw the states (SUBMITTED → SCHEDULED → PENDING → RUNNING → SUCCEEDED/FAILED/RETRYING → DEAD). This immediately shows you understand the full system.
Delayed queue is the core challenge — Don't handwave "just use a queue." Explain why 500M delayed tasks need a sorted data structure (Redis ZSET), how the poller works (ZRANGEBYSCORE every 100ms), and why DB polling doesn't scale.
Pull vs Push is a key tradeoff — Explain why pull is better: workers self-regulate via backpressure, no centralized scheduler bottleneck, natural load balancing. Mention BRPOP for blocking pull (no polling waste).
Exactly-once is a lie (but achievable in practice) — Explain the reality: at-least-once delivery + idempotent execution = effectively exactly-once. The lease + heartbeat + idempotency key pattern is critical to understand.
Retry strategy with jitter — Don't just say "retry 3 times." Explain exponential backoff (60s → 120s → 240s), why jitter prevents thundering herd, and how error classification (retryable vs permanent) saves worker capacity.
Cron double-fire prevention — Multiple scheduler nodes evaluating crons = duplicate tasks. Solution: optimistic CAS on next_fire_time. Show the SQL:
UPDATE ... WHERE next_fire_time = :expected. Only one node wins.DAG execution engine — Draw the dependency graph. Explain how the engine evaluates after each task completion: "are all dependencies of X met? If yes, enqueue X." Show parallel execution of independent tasks.
Multi-tenant fairness — One tenant's 1M-task burst shouldn't starve others. Explain per-tenant queues with weighted round-robin dispatch. Connect to real-world: SaaS platform serving multiple customers.
Worker auto-scaling — Scale workers based on queue depth, not CPU. Kubernetes HPA with custom metric:
queue_depth / desired_processing_rate = target_workers. Show the feedback loop.End with observability — Task schedulers are notoriously hard to debug. Mention distributed tracing per task (submission → queue wait → execution), DLQ alerting (tasks that permanently fail need human attention), and cron fire accuracy monitoring.