Durable Timers
Durable timers pause a workflow without keeping a worker slot busy. Use them when a handler needs to wait before continuing, but the wait itself must survive worker restarts and deploys:
- pacing retries against a third-party API
- waiting before a human follow-up reminder
- backing off an agent loop after a rate limit or empty poll
jobs.Sleep waits for a duration. jobs.SleepUntil waits until a specific
time. Both functions may only be called from inside a registered job handler.
queue.Register("agent.poll", func(ctx context.Context, args PollArgs) error {
for attempt := 0; attempt < 12; attempt++ {
result, err := jobs.Call[PollResult](ctx, "poll-provider", PollAttempt{
AccountID: args.AccountID,
Attempt: attempt,
})
if err != nil {
return err
}
if !result.Empty {
_, err = jobs.Call[any](ctx, "handle-result", result)
return err
}
if err := jobs.Sleep(ctx, 30*time.Second); err != nil {
return err
}
}
return nil
})Each Sleep is checkpointed by call index. In a bounded loop, every iteration
reaches a distinct call index, so each empty poll creates its own timer while
already-resolved timers replay instantly.
queue.Register("trial.followup", func(ctx context.Context, args FollowupArgs) error {
if _, err := jobs.Call[any](ctx, "send-welcome", args.UserID); err != nil {
return err
}
followupAt := args.StartedAt.Add(72 * time.Hour)
if err := jobs.SleepUntil(ctx, followupAt); err != nil {
return err
}
_, err := jobs.Call[any](ctx, "send-followup", args.UserID)
return err
})What Happens While Sleeping
On the first execution, the timer records a checkpoint with the original
deadline, moves the job to StatusWaiting, stores the wake deadline in
run_at, clears the worker lock, and returns control to the worker. The worker
slot is free for another job.
After the deadline, any worker in the fleet can resume the job. The handler is
replayed from the beginning, reaches the same Sleep or SleepUntil call, sees
the recorded deadline, and continues without sleeping again.
jobs.Call or an explicit phase checkpoint. See
Execution semantics: at-least-once.If a worker crashes while a job is sleeping, no in-process state is needed. The
checkpoint and run_at row are enough for another worker to resume the job
after the original deadline. A jobs.Sleep(ctx, 10*time.Minute) does not become
another 10-minute wait after restart.
Wake Granularity
Timers are durable scheduling primitives, not precise clocks. Elapsed sleeps are detected by the worker’s waiting-job polling backstop, currently about every 5 seconds, plus the normal dispatch poll. A sleeping job can therefore resume several seconds after its deadline.
Use timers for backoff, scheduling gaps, reminders, and workflow pacing. Do not use them for sub-second timing or latency-sensitive coordination.
Fast Paths
Non-positive durations return immediately:
if err := jobs.Sleep(ctx, delay); err != nil {
return err
}Times in the past also return immediately:
if err := jobs.SleepUntil(ctx, deadline); err != nil {
return err
}Both fast paths still record a resolved checkpoint. That matters for replay: if
a later deploy changes delay or deadline, an already-executed timer keeps
returning the result it recorded originally.
Signals Are Separate
User signals cannot complete or consume a durable timer. Signal names beginning
with _ are reserved for library internals and Queue.Signal rejects them with
jobs.ErrSignalNameReserved.
Versioning Timer Changes
Timer calls are part of workflow shape. Changing a sleep duration for new jobs is
usually fine because the original deadline is checkpointed for in-flight runs.
Changing whether a timer exists, moving it relative to jobs.Call, fan-out, or
signal waits, or changing the branch that reaches it should use
jobs.GetVersion.
version, err := jobs.GetVersion(ctx, "agent-backoff-v2", jobs.DefaultVersion, 1)
if err != nil {
return err
}
switch version {
case jobs.DefaultVersion:
return jobs.Sleep(ctx, 10*time.Second)
default:
return jobs.Sleep(ctx, time.Minute)
}See Workflow Versioning for the branch pattern used to evolve in-flight workflows safely.