R4.04 · Time, Delay & Priority
Backoff & retry
A failed job does not retry immediately. Re-schedule it into the future with a delay that grows on each attempt — a delayed job whose fire-time is a backoff formula — so a struggling dependency is not hammered.
This module is the delayed queue (R4.01) applied to retries. The textbook note is one line: a
failed task re-adds itself with a future score (ZADD delayed_queue <now + 60>
"task:abc123"), and the retry count tracked in the job data drives an exponential backoff or a maximum
retry limit. R4.04 takes that idea apart — the failed job re-enters the same delayed sorted set, scored by
a fire-time the backoff formula computes, and is swept back when due. The formula that sizes the delay is
EchoMQ.Backoff.calculate/4, in Elixir; the reschedule and the sweep are the Lua R4.01
already taught. One set, two uses.
A retry is a delayed job
When a job fails, the worker does not drop it and does not loop on it. It re-schedules the job to run again later — and "later" is a fire-time, the exact axis the delayed queue already orders by. The source shows the minimal form: re-add the failed job with a future score.
ZADD delayed_queue 1706649060 "task:abc123" # a retry, scheduled 60s out
The member is the same job; the score is now + 60s. The delayed set sorts it by that
fire-time alongside every other deferred job, and the due-sweep brings it back when the clock reaches it. The
retry rides the machinery R4.01 already built — the only new question is what delay goes in the score. The
interactive below fails a job at t = 0 and plots five exponential retries: move the base
slider and read the next fire-time and the cumulative wall-clock.
A retry timeline · five exponential attempts from one failure
A retry is a delayed job: re-add the failed job to the delayed set at a future score, and the same due-sweep brings it back when its time comes.
The backoff formula (exponential)
A fixed delay treats a transient blip and a struggling dependency the same. A backoff delay grows with the attempt, so the first retry is quick and a repeatedly-failing dependency gets progressively more room. The exponential form doubles the delay each attempt.
# delay = base × 2^(attempt - 1) attempt 1 → base × 2^0 = base attempt 2 → base × 2^1 = 2 × base attempt 3 → base × 2^2 = 4 × base
With a one-second base the retries land at 1s, 2s, 4s, 8s, 16s out. In EchoMQ this math is owned by
EchoMQ.Backoff.calculate/4 — Elixir, not Lua. The :exponential
clause computes delay = trunc(:math.pow(2, attempt - 1) × base_delay) then
apply_jitter(delay, jitter). The doc example is exact:
calculate(:exponential, 3, 1000, jitter: 0.2) returns about 4000.
The :fixed strategy is the contrast — it returns
apply_jitter(base_delay, jitter), the same base every attempt.
The ownership matters: the formula computes the delay value, and the reschedule script is handed that
value. The Lua never computes 2^(n-1). The first dive walks the formula in full.
Exponential backoff doubles the delay each attempt, and the doubling lives in
EchoMQ.Backoff.calculate/4 — the reschedule script is given the delay, it does not
compute the power.
Jitter — breaking the thundering herd
Exponential backoff alone has a failure mode. If many jobs fail at the same instant — a dependency that went down took them all out together — they all compute the same delay and all re-fire at the same future millisecond. The recovery becomes a synchronized spike: the thundering herd. The dependency, newly back on its feet, is hit by the whole batch at once.
Jitter spreads the retries. EchoMQ.Backoff applies it in
apply_jitter/2: for a jitter fraction in (0, 1], it computes
min_delay = trunc(delay × (1 - jitter)) and
jitter_range = trunc(delay × jitter × 2), then returns
min_delay + :rand.uniform(jitter_range + 1) - 1. With jitter: 0.2
on a 4000 ms delay, each retry lands somewhere in [3200, 4800] — the delay plus or
minus twenty percent. The default config carries it:
%{type: :exponential, delay: 1_000, jitter: 0.2}. A herd that would have re-fired in
one spike is smeared across a window instead. The second dive runs the spread.
A fixed, exact delay re-synchronizes a failed batch into a spike; jitter widens each delay into a band and draws an independent point per job, so the herd disperses across a window.
Reusing the delayed ZSET (no new structure)
A retry is not a new mechanism. It is R4.01's machinery used a second time.
EchoMQ.Scripts.retry_job/5 runs retryJob-11.lua, which re-adds
the failed job to the delayed key (KEYS[7]) scored by the backoff fire-time. That
script includes promoteDelayedJobs — the same due-sweep R4.01 teaches.
-- promoteDelayedJobs (included by retryJob-11.lua) — real local jobs = rcall("ZRANGEBYSCORE", delayedKey, 0, (timestamp + 1) * 0x1000 - 1, "LIMIT", 0, 1000) if (#jobs > 0) then rcall("ZREM", delayedKey, unpack(jobs)) -- then ZADD onto the wait / prioritized list end
The retry re-enters emq:{queue}:delayed exactly the way a freshly-scheduled delayed
job does — scored by getDelayedScore ((fire-time) × 0x1000)
— and is swept back when the clock reaches it. R4.01 schedules; R4.04 sizes the delay with
EchoMQ.Backoff and re-schedules on failure. The structure does not change between them.
The third dive walks the reuse end to end.
A retry adds no structure: it reuses R4.01's delayed set, score shift, and
promoteDelayedJobs sweep, contributing only the backoff delay that sets the
fire-time.
Capping — max attempts
A retry loop needs a floor. A job that fails forever should not retry forever. The source's own note is
"maximum retry limits": track the attempt count in the job data and stop re-scheduling once it crosses
a configured maximum. In EchoMQ the attempts limit is part of the job's options
(attempts: 5 in the Backoff doc examples), checked on each
failure before retry_job/5 is called; past the limit the job moves to the failed set
instead of back to delayed. The backoff formula sizes the delays; the attempts cap fixes how many of them there
are.
The backoff formula sizes each retry's delay; the attempts cap bounds the count, sending an over-budget job to the failed set instead of back to the delayed one.
When backoff hurts (latency-sensitive work)
Backoff trades latency for protection. That trade is right for work that tolerates a delayed retry — an email, a webhook, a batch import — and wrong for work that must complete now or not at all. A user-facing request waiting on a synchronous response cannot afford an 8-second backoff before its second attempt; it needs a fast in-line retry or an immediate failure, not a deferred one. Backoff also assumes the failure is transient and the dependency will recover; against a permanent failure it only delays the inevitable trip to the failed set, so the attempts cap should be small.
For protecting a shared dependency from sustained load — not merely spacing one job's retries — rate limiting and the circuit breaker (R6 · Flow Control) are the right tools; backoff spaces one job's own retries, it does not cap the fleet.
| Backoff fits | Reach elsewhere when |
|---|---|
| Work that tolerates a deferred retry: email, webhook, batch import | A user-facing request needs a result now — fast in-line retry or fail |
| A transient failure against a recovering dependency | A permanent failure — cap attempts small, the failed set is the destination |
| Spacing one job's own retries over time | Protecting a shared dependency from the fleet — rate limiting or the circuit breaker (R6) |
Reach for backoff when a deferred retry is acceptable and the failure is transient; reach for rate limiting or a circuit breaker when a shared dependency needs protection from the whole fleet, not one job's spacing.
The three dives
Each dive takes one layer of retry-with-backoff, building on the source's retry idea and adding the EchoMQ grounding on top: the formula that sizes the delay, the jitter that spreads a batch, and the reuse of R4.01's delayed set. Read them in order — the formula first, the jitter second, the reuse third.
Exponential backoff
The formula delay = base × 2^(attempt-1) — attempt 1 waits base,
2 waits 2×, 3 waits 4×. Owned by EchoMQ.Backoff.calculate/4 in Elixir, not
the Lua.
Jitter & the thundering herd
A synchronized batch re-fires as one spike; apply_jitter spreads it —
jitter: 0.2 scatters a 4000 ms delay across [3200, 4800].
Reusing the delayed ZSET
A retry is R4.01's machinery reused: retryJob-11.lua re-adds to
:delayed, the included promoteDelayedJobs sweeps it back. No
new structure.
A delayed queue (R4.01) schedules a job for a future time. A retry is the same move, with the future time computed by a backoff formula that grows the delay each attempt.
EchoMQ.Backoff.calculate/4 sets the delay (base × 2^(n-1),
jittered); retryJob-11.lua re-adds the job to emq:{queue}:delayed
at that score; the included promoteDelayedJobs (R4.01's sweep) brings it back when
due. One structure, two uses.
A retry is a delayed job whose fire-time is a backoff delay; the formula lives in Elixir
(EchoMQ.Backoff), and the reschedule and the sweep live in the Lua R4.01 already
taught.
Where this is heading — EchoMQ 2.0
Today a retry lands on emq:{queue}:delayed and is swept by
promoteDelayedJobs. The EchoMQ 2.0 protocol break — the first rung of the EMQ
roadmap — renames this key to emq:{queue}:delayed, applies the
{queue} hashtag transparently in the core, declares every Lua key in
KEYS[], and bumps meta.version from
bullmq:5.65.1 to echomq:2.0.0 behind a two-way typed boot
fence. The v1 line freezes at 1.3.0; the payoff is DragonflyDB-native multithreading
with precise per-queue locks and no escape-hatch flags.
The backoff math (EchoMQ.Backoff.calculate/4) and the due-sweep
(promoteDelayedJobs) are unchanged by the break — only the prefix and the
now-fully-declared keys change. That is why retry-with-backoff is safe to teach before the break ships: the
pattern is a property of the sorted set — the score, the doubling, the sweep — not of the prefix it
rides on.
A door, not a depth
This module cites one excerpt of EchoMQ's protocol — EchoMQ.Backoff.calculate/4
and the reschedule in retryJob-11.lua — as proof the retry path ships. The full
lifecycle subsystem — how a failed job moves through retry, checkpoint, and the attempts cap across a
worker pool, and the polyglot concurrency models — is the subject of the dedicated
EchoMQ course, built next with this toolkit. From here, open onto
E6 · Lifecycle controls. This module teaches retry-with-backoff; that
course teaches the lifecycle that runs it.
After R4.04, the Time, Delay & Priority chapter continues with leaderboards on the prioritized set, closing with the EchoMQ scheduler workshop. Return to the Time, Delay & Priority chapter to continue.
References
The Redis commands behind a retry — the re-add at a backoff fire-time, the due-sweep that brings it back, the sorted set both reuse — the queue protocol EchoMQ ports, the direction EchoMQ 2.0 takes native, and where the lifecycle that runs the retry is taught.
Sources
- Redis — ZADD — re-add the failed job to the delayed set, scored by the backoff fire-time.
- Redis — ZRANGEBYSCORE — the due-sweep (
promoteDelayedJobs) that brings a retry back to wait. - Redis — Sorted sets — the timer-wheel the retry reuses, the one set R4.01 already built.
- BullMQ — the queue protocol — the retry-and-backoff protocol EchoMQ ports, where the Lua scripts are the protocol.
- DragonflyDB — BullMQ on Dragonfly — the BullMQ-on-Dragonfly direction the EchoMQ 2.0 protocol break takes native.
Related in this course
- R4.04.1 · Exponential backoff — the formula, owned by
EchoMQ.Backoff. - R4.04.2 · Jitter & the thundering herd — spreading a synchronized batch.
- R4.04.3 · Reusing the delayed ZSET — R4.01's set, used twice.
- R4.01 · The delayed queue — the
:delayedmachinery this reuses. - R4.02 · Scheduler registry — recurring jobs on the sibling
:repeatset. - R4 · Time, Delay & Priority — the chapter.
- E6 · Lifecycle controls — the dedicated EchoMQ course: the retry / checkpoint lifecycle in depth.