Engineering Atlas › Low-Level Design

Code a task scheduler

12 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

Requirements

Functional:

Non-functional:

Out of scope: persistence across restarts, distribution across machines (that is a job scheduler service), cron expressions and time zones, and a worker pool. The design leaves room for each.

Entities and relationships

Scheduler owns three things: a map from task ID to Task, a min-heap of entries, and a sequence counter. It also holds the injected clock and the base backoff.

Task is the unit the user cares about: ID, action, period (zero for one-off), current slot, retry limit, retries used so far, status and a version number. The version is how the heap and the map stay consistent without searching the heap.

HeapEntry is a small value: (runAt, seq, taskId, version). runAt orders the heap, seq breaks ties in insertion order, and version lets the scheduler tell a live entry from a stale one. A task has at most one live entry at a time.

Action is the callback. It receives its scheduled time and signals failure by throwing (Python, TypeScript) or returning an error (Go).

Clock is a function returning the current time. schedule reads it to turn a delay into an absolute time; tick(now) takes the time as an argument.

RetryPolicy is folded into the scheduler here as backoff * 2^(retry - 1), and is the obvious thing to extract as a strategy when requirements grow.

Class diagram

classDiagram
  class Scheduler {
    -tasks Map
    -heap MinHeap
    -seq int
    -clock Clock
    -backoff int
    +scheduleOnce(id, delay, action, maxRetries) bool
    +scheduleAtFixedRate(id, initialDelay, period, action, maxRetries) bool
    +cancel(id) bool
    +status(id) string
    +tick(now) list
  }
  class Task {
    +id
    +action Action
    +period int
    +slot int
    +maxRetries int
    +retries int
    +status string
    +version int
  }
  class HeapEntry {
    +runAt int
    +seq int
    +taskId
    +version int
  }
  class Action {
    <<interface>>
    +run(at)
  }
  class Clock {
    <<interface>>
    +now() int
  }
  class RetryPolicy {
    <<interface>>
    +delay(retry) int
  }
  Scheduler "1" *-- "many" Task
  Scheduler "1" *-- "many" HeapEntry
  HeapEntry ..> Task : points to by id and version
  Task --> Action
  Scheduler --> Clock
  Scheduler ..> RetryPolicy

Key flows

One tick(10) with a flaky one-off task once and a recurring task rate that always fails, both first due at time 0 with a base backoff of 1.

sequenceDiagram
  participant D as Dispatcher
  participant S as Scheduler
  participant H as MinHeap
  participant A as Task action
  D->>S: tick(10)
  S->>H: peek root, runAt 0 is due
  H-->>S: pop (0, once)
  S->>A: once.run(0)
  A-->>S: throws
  S->>H: push (1, once), retry 1
  S->>H: pop (0, rate)
  S->>A: rate.run(0)
  A-->>S: throws, no retries left
  S->>H: push (10, rate), next slot
  S->>H: pop (1, once)
  S->>A: once.run(1)
  A-->>S: ok, status done
  S->>H: pop (10, rate)
  S->>A: rate.run(10)
  A-->>S: throws
  S->>H: push (20, rate)
  S->>H: peek root, runAt 20 is not due
  S-->>D: once@0 retry, rate@0 failed, once@1 ok, rate@10 failed

The loop is "while the root is due, pop it, skip it if stale, run it, decide what comes next". Anything pushed during the tick with a time at or before now, such as a retry or a task scheduled by another task, runs in the same tick, still in time order.

Design patterns used and why

Concurrency and edge cases

Down the stack

Layer 1The contract

tick(now) runs every live task whose next run time is at or before now, in increasing order of (runAt, seq), including tasks pushed during the same tick. Each run produces one outcome: ok, retry or failed. A one-off task ends done or failed; a recurring task stays scheduled until cancelled. A cancelled task never starts again. An action failure never escapes tick. Nothing in the core reads time except through the injected clock.

Layer 2The mechanism

The heap is an array where the children of index i are at 2i+1 and 2i+2. Push appends and sifts up; pop moves the last element to the root and sifts down, each in at most log2(n) swaps. Python's heapq does exactly this on a list of tuples, comparing (runAt, seq, ...) lexicographically. Go's container/heap asks you to implement Len, Less, Swap, Push and Pop. Java's ScheduledThreadPoolExecutor uses its own DelayedWorkQueue, a heap that also stores each task's index so it can remove cancelled tasks directly; it offers setRemoveOnCancelPolicy because lazy deletion leaves cancelled tasks in the queue. Linux and Kafka handle millions of short timers with timing wheels instead: an array of buckets indexed by time modulo the wheel size, giving O(1) insert at the cost of coarse granularity.

Layer 3The machine

A heap of 1 million entries is 20 levels deep. Each pop walks one path from root to leaf, about 20 comparisons and swaps, and the lower levels miss the CPU cache at roughly 100 ns per RAM access, so a pop costs around 1 to 2 microseconds. A heap of 1,000 tasks fits in L1 and L2 cache and pops in tens of nanoseconds. In production the dispatcher does not spin: it sleeps until the root's deadline or until a new earlier task arrives, using a condition variable with a timeout, a Go time.Timer reset to the new deadline, or epoll_wait with a timeout on Linux. Wakeups are accurate to tens of microseconds on an idle machine and to milliseconds under load, so a scheduler promises "not before" and "soon after", never "exactly at". Use a monotonic clock for delays: wall-clock time jumps when NTP corrects it, and a task scheduled "in 5 seconds" must not wait an hour because the clock moved back.

Why it's built this way

  1. The only question the dispatcher asks is "what is due next?", so the structure that answers "minimum" cheaply, a heap, is the core.
  2. Removing arbitrary heap entries needs an index map that must be kept consistent on every swap, so most implementations accept stale entries and validate by version instead.
  3. Time makes tests slow and flaky, so time becomes a parameter, and the production loop is a thin shell around a pure tick(now).
  4. User code is untrusted and re-entrant, so it runs outside the lock and its failures are caught and turned into retry decisions.

Extensibility

Leadership lens

Check yourself

  1. Why does the heap entry carry a version, and what would go wrong without it?

    Show answer

    Cancelling or rescheduling leaves the old entry in the heap. Without a version check, the old entry would run a cancelled task or run a rescheduled task twice.

  2. A fixed-rate task with period 10 started at 0 takes 3 units to run. When does it next run, and how is that different from fixed delay?

    Show answer

    At 10, because the next slot is the previous slot plus the period. Fixed delay would run at 13 (finish plus delay) and drift by the run time on every cycle.

  3. Why is seq part of the heap key?

    Show answer

    Heaps do not preserve insertion order for equal keys, so tasks due at the same time would run in an arbitrary order. seq makes the order first-scheduled, first-run and the tests deterministic.

  4. What breaks if the Go scheduler holds its mutex while calling the task's action?

    Show answer

    Any action that calls Cancel or a schedule method on the same scheduler deadlocks, and a slow action blocks every thread that wants to schedule or cancel.

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →