Retries & backoff
Durable step retries with fixed/exponential backoff and jitter (a failed step re-dispatches on a persisted wakeAt), FatalError and worker-side retryable:false to opt out, and the in-memory timeoutMs + heartbeat liveness path for presumed-dead workers.
Transient failures are the normal case for anything that crosses a network boundary, so every step is retryable. A step is always dispatched to a worker and the engine handles its failures along one durable retry path: the run suspends between attempts rather than holding a worker (or the orchestrator) in memory, so a step survives the worker — and even the orchestrator — disappearing mid-flight.
Retries & backoff
A step's retry policy is declared on its @Step handler and applies wherever the step is dispatched. If
the handler throws, the engine retries it up to retries attempts, spacing the attempts with the step's
backoff configuration:
@Step({ retries: 5, backoff: 'exp', backoffMs: 200, backoffMaxMs: 10_000, jitter: true })
async fetchQuote(order: Order): Promise<Quote> {
return this.pricing.fetch(order);
}
// in the workflow — the declared policy applies:
const quote = await ctx.step(this.pricing.fetchQuote, order);1@Workflow({ name: 'checkout', version: '1' })2export class CheckoutWorkflow {3 constructor(4 private readonly pricing: PricingService,5 private readonly payments: PaymentsService,6 private readonly email: EmailService,7 ) {}8 9 async run(ctx: WorkflowCtx, order: Order) {10 const quote = await ctx.step(this.pricing.fetchQuote, order);11 const charge = await ctx.step(this.payments.chargeCard, order);12 await ctx.step(this.email.confirm, { order, charge }, { retries: 5 });13 return charge.id;14 }15}And as a live system — watch the backoff double after each failed attempt while the run sits suspended:
wakeAt — watch the countdown double on the next failure (exponential backoff) — and the re-dispatch finally lands (✓). No worker is held while it waits, and the pending retry survives a crash or deploy.The options are:
retries— maximum number of attempts before the step (and the run) fails. Defaults to 1 (a single try).backoff— how the delay between attempts grows:'fixed'keeps it constant,'exp'doubles it each attempt.backoffMs— the base delay in ms. Omit (or set to 0) to retry with no delay at all.backoffMaxMs— an upper bound, so an exponential backoff doesn't grow without limit.jitter— adds random jitter (50–100% of the computed delay) so a fleet of runs retrying the same downstream don't synchronize into a thundering herd.
Every one of these is also a per-call override on ctx.step(handler, input, opts): the effective policy is
the @Step-declared one with any opts field winning, so a single call site can tighten or relax the
default without changing the handler.
With backoff: 'exp', backoffMs: 200 the delays before attempts 2, 3, 4… are 200ms, 400ms, 800ms… until
they hit backoffMaxMs. With jitter: true each of those is then scaled to a random point in its top half,
so two runs that failed at the same instant don't retry in lockstep.
FatalError — never retried
Not every failure is worth retrying. A declined card or invalid input will fail the same way on every
attempt, so burning the retry budget on it just delays the inevitable. Throw a FatalError inside the
handler to fail the run immediately, regardless of the step's retries:
import { FatalError } from '@dudousxd/nestjs-durable-core';
@Step()
async charge(order: Order) {
const res = await this.stripe.charge(order);
if (res.declined) throw new FatalError('card declined', 'declined');
return res;
}The optional second argument is a machine-readable code (here 'declined') that ends up on the run's
structured error. Use FatalError for deterministic business verdicts; let ordinary throws (a timeout, a
502, a dropped connection) flow through the retry path.
The durable retry path
A step runs on a worker — possibly in another process or another language — so the engine handles its
failures durably rather than by re-running a function in a loop. When a step has no timeoutMs, the engine
dispatches the task, persists a pending checkpoint, and suspends the run durably — it is not held in
memory awaiting the result. Whichever instance receives the worker's result resumes the run, so the worker
pod (and even the dispatching orchestrator) can scale down or crash mid-step without losing the run or
re-running completed work.
@Injectable()
export class PaymentsService {
@Step({
name: 'payments.charge-card',
input: z.object({ orderId: z.string(), amountCents: z.number().int() }),
output: z.object({ chargeId: z.string() }),
retries: 4,
backoff: 'exp',
backoffMs: 500,
backoffMaxMs: 30_000,
jitter: true,
})
async chargeCard(input: { orderId: string; amountCents: number }): Promise<{ chargeId: string }> {
return { chargeId: await this.stripe.charge(input) };
}
}
// in the workflow:
const charge = await ctx.step(this.payments.chargeCard, { orderId: order.id, amountCents: order.total });When a worker reports a failed result for a step, the engine consults retries. If the attempt
budget remains, it re-dispatches — but durably, not in a loop. The first time it sees the failed checkpoint
it computes the next retry deadline as now + backoffDelay(attempt) and stamps it on the failed
checkpoint's wakeAt, in clock-space, then suspends. Because that deadline is persisted on the
checkpoint rather than living in a timer in memory, it is replay-stable (a resume recomputes the same
decision) and crash-safe (a process that dies before the retry fires picks it back up when the timer poller
sees the wakeAt come due). Once the deadline passes, the poller resumes the run, the call re-dispatches
with an incremented attempt, and the cycle continues until the result lands or retries is exhausted.
Opting out: retryable: false
A dispatched step retries on a failed worker result unless the worker marks the error as
non-retryable. A worker that reports an error has returned a deterministic verdict — a declined card, a
validation failure — so re-dispatching it just hammers the worker for the same answer. Set retryable: false
on the StepError the worker returns and the engine surfaces it to the workflow immediately instead of
retrying:
// inside the worker handler (TypeScript or, symmetrically, the Python SDK):
@Step('payments.charge-card')
async charge(input: { orderId: string; amountCents: number }) {
const res = await this.stripe.charge(input);
if (res.declined) {
// a deterministic verdict — don't make the engine retry it
throw Object.assign(new Error('card declined'), { code: 'declined', retryable: false });
}
return { chargeId: res.id };
}The default is retryable !== false, i.e. an ordinary error is retried; only an explicit retryable: false
opts out. This is the worker-side counterpart of throwing FatalError in a step handler.
The in-memory liveness path (timeoutMs)
Setting timeoutMs on a step opts it into a different path entirely. timeoutMs is a liveness
window: if the worker produces neither a result nor a heartbeat within that many ms, the engine presumes
it dead, fails the dispatch with a RemoteStepTimeout, and — because that timeout is retryable —
re-dispatches it in-place up to retries. Each heartbeat the worker emits (via transport.onHeartbeat)
rearms the window, so a long but healthy step that keeps beating stays alive well past timeoutMs.
@Step({
name: 'media.render',
input: z.object({ assetId: z.string() }),
output: z.object({ url: z.string() }),
timeoutMs: 60_000, // presume the worker dead after 60s of silence (no result, no heartbeat)
retries: 3,
})
async render(input: { assetId: string }): Promise<{ url: string }> {
return { url: await this.encoder.render(input.assetId) };
}The crucial difference between the two paths: the durable path retries on a worker reporting a failure
and suspends between attempts (crash-safe, not in memory); the liveness path retries on a worker going
silent and awaits the result in memory between attempts. They also disagree on what a reported error
means — the durable path retries it (subject to retryable), while a timeoutMs step only re-dispatches on
a timeout, surfacing any reported error to the workflow on the first attempt. Reach for timeoutMs only when
you genuinely need to detect and replace a stuck worker; otherwise leave it off and get the durable,
crash-safe path.
A third case: the dispatch itself gets lost
Neither path above covers a step whose job never produces a result at all — no failure, no
timeoutMs heartbeat to time out — because the worker crashed mid-step or the transport dropped the job
(a Redis flush/eviction, or a stalled BullMQ job moved to failed and removed). Reconcile re-drives are
deliberately conservative here: they re-suspend a still-pending step rather than re-dispatching it, so
a merely-slow worker is never double-run. Without a timeoutMs, that means the run hangs on pending
forever unless something re-dispatches it:
-
The BullMQ transport closes this gap for the consumers that implement the bridge: a crashed/stalled task job's terminal failure is turned into a synthetic failed
StepResult, so the checkpoint fails and the normal durable retry path above kicks in. The TypeScript transport and the Pythondurable-workerboth do this; a worker on any other SDK (or an olderdurable-worker) does not, and its steps staypending. -
For any transport,
engine.redispatchPending(runId)(or the dashboard's Re-dispatch action) manually re-enqueues a run's stuckpendingremote steps. -
remoteRedispatchMs— an opt-in engine option — makes this self-healing: a dispatched remote step carries a lease on itspendingcheckpoint (wakeAt), and the run suspends on it (never later than thereconcileMssweep would have woken it anyway). A result — or a step-scoped heartbeat, which renews the lease (throttled to one write per half-window, so a worker beating every couple of seconds is not anUPDATEstorm) — must land before it lapses; a lapsed lease re-dispatches the samestepId, bounded byremoteRedispatchMax(default 10) before failing the step asremote_step_lost(which reaches the workflow's own error path). Off by default, because re-dispatch can double-run a step whose job is merely slow and does not beat: the window must exceed the longest legitimate duration of a non-heartbeating step, and the step must be idempotent.This covers both shapes of remote step: an in-process
ctx.stepand a polyglot workflow'scallfan-out (ctx.gather_calls). Before@dudousxd/nestjs-durable-core0.71, the fan-out path ignored the option entirely — a lost job there was skipped on every subsequent turn and the run waited forever. If you wire the engine throughDurableModule, note that the option is only forwarded from@dudousxd/nestjs-durable0.71 as well.A re-drive is observably different from a retry: the
step.startedevent carriesredispatched: true, and the checkpoint's own event trail gets awarnstep.redispatched(step.lostwhen the bound runs out). Alert on those to see a worker that is dying under you rather than steps that merely fail.
DurableModule.forRoot({
// Longer than your longest remote step. A step that heartbeats renews its own lease, so this
// window only ever has to cover steps that go silent.
remoteRedispatchMs: 45 * 60_000,
remoteRedispatchMax: 3,
});See Recovering a lost remote step dispatch for the dashboard side of this (the stale-pending flag and the Re-dispatch button).
Reliability
How nestjs-durable keeps long-running work correct in the face of transient failures, crashes and overload — step retries, saga compensation, durable flow-control queues and the dead-letter queue.
Sagas & compensation
Undo a dispatched step's side effects with a compensate ref/name that receives a StepUndo<TInput, TOutput> envelope, retried per the undo's own @Step config and checkpointed at negative seqs for crash-safe resume — plus the ctx.localStep closure form, compensationRetries, compensate:<step> visibility, and compensating cancellation via engine.cancel(runId, { compensate: true }).