Agora
Concepts

Sleep & signals

Pause a workflow durably — ctx.sleep for time-based waits (minutes to months, no compute), ctx.waitForSignal for human approvals and webhooks, and ctx.waitForEvent for name-based pub/sub with reliable (buffered) delivery, all surviving restarts.

Real workflows wait — for a delay, or for the outside world. Both kinds of pause are durable: the run is suspended (no compute consumed) and resumes on its own, even across restarts.

1async run(ctx: WorkflowCtx, order: Order) {2  await ctx.step(this.orders.place, order)3 4  // durable timer — suspend 2h, survives restarts:5  await ctx.sleep('2h')6 7  // or wait for an external signal (webhook, approval):8  const approval = await ctx.waitForSignal(`approved:${order.id}`)9  await ctx.step(this.orders.finalize, { order, approval })10}
run settles — completedplacesleep 2hwaitingsignal →finalizedone
completesThe body returns; the run completes.
6 / 6

Durable sleep

ctx.sleep(duration: string | number): Promise<void> suspends the run until the timer is due. Accepts milliseconds as a number, or a duration string such as '500ms', '30s', '15m', '2h', '7d', '7 days', '1w' or '2 weeks' (units ms, s/sec/second(s), m/min/minute(s), h/hr/hour(s), d/day(s), w/week(s), case-insensitive; a decimal value such as '1.5h' works too). An unparseable string throws invalid duration: … or unknown duration unit: … in the body. parseDuration is exported from @adonis-agora/durable if you need the same parsing elsewhere:

await ctx.localStep('draft', () => generate(topic))
await ctx.sleep('7 days') // gather signals for a week — nothing running
await ctx.localStep('refine', () => refine(topic))

The first time this line runs, the engine computes the absolute wake time, records it as a sleep-kind checkpoint (kind: 'sleep', wakeAt), and suspends the run — the run becomes suspended and no compute is consumed while it waits.

What wakes it: a poller (the durable:work loop, or the embedded worker, via engine.resumeDueTimers() on every tick — default every second, --interval) resumes runs whose stored wake time has passed. There is no setTimeout held in memory per sleep, so a restart loses nothing. On resume, the steps before the sleep replay from their checkpoints.

Replay: on any re-run, the engine reads the recorded checkpoint first. If the clock has passed the recorded wakeAt it returns immediately (the sleep is done); otherwise it re-suspends on the same wakeAt. The wake time is fixed by the first run's checkpoint, not recomputed on replay — so a clock change or a redeploy can't shift an already-scheduled timer.

To wait until a specific moment instead of for a duration, use ctx.sleepUntil(when) — it takes a Date or an epoch-ms number:

await ctx.sleepUntil(new Date('2026-12-31T00:00:00Z'))

sleepUntil records the same sleep checkpoint as sleep, so it replays the same way. A target already in the past still records the checkpoint and suspends once; the next timer pass (resumeDueTimers) resumes it straight away.

To delay a run's first step rather than pause in the middle, don't open the body with a sleep: pass startAt to engine.start and the run is created parked until then, with no sleep in its history. See Delayed starts.

Capture the target time deterministically — derive it from a checkpointed value (a step result, ctx.now()), never from a raw Date.now() in the body. See Versioning & determinism.

Signals (human-in-the-loop)

ctx.waitForSignal(token) suspends the run — with no timer — until an external engine.signal(token, payload) arrives. Perfect for approvals, webhooks, or any third-party callback.

const decision = await ctx.waitForSignal<{ approved: boolean }>(`approve:${order.id}`)
if (!decision.approved) return { status: 'rejected' }

Deliver the signal from anywhere — typically a controller — via engine.signal:

app/controllers/approvals_controller.ts
import type { HttpContext } from '@adonisjs/core/http'
import engine from '@adonis-agora/durable/services/main'

export default class ApprovalsController {
  async approve({ params, request, response }: HttpContext) {
    await engine.signal(`approve:${params.id}`, request.body()) // resumes the run with the payload
    return response.noContent()
  }
}

The payload is checkpointed like any step result, so the resumed run sees the same decision even if it replays again later. engine.signal returns the resumed run's result, or null when no run was waiting (the payload was buffered — see below).

The token is a global namespace, not run-scoped. Unlike ctx.onUpdate(name) — where the engine prefixes name with the run id internally (update:${runId}:${name}) — waitForSignal uses the token you pass verbatim. On the Lucid store the waiter row is keyed by token alone (token is the primary key of durable_signal_waiters), so a second waiter registering an already-waited-on token replaces the first. Always embed the run's identity in the token (`approve:${order.id}`, not 'approve') so it is unique across the whole engine.

Replay: the wait consumes one logical position (two with { timeoutMs }, see below); on replay, a completed checkpoint at that position returns its saved payload instead of waiting again, exactly like a step.

Pass { timeoutMs } to bound the wait — if the deadline passes before a signal arrives, waitForSignal throws SignalTimeoutError, which you can catch to take a default branch:

import { SignalTimeoutError } from '@adonis-agora/durable'

try {
  const decision = await ctx.waitForSignal<{ approved: boolean }>(`approve:${order.id}`, {
    timeoutMs: 24 * 60 * 60 * 1000,
  })
  return decision.approved ? { status: 'approved' } : { status: 'rejected' }
} catch (err) {
  if (err instanceof SignalTimeoutError) return { status: 'expired' }
  throw err
}

This is the supported, replay-safe way to race "signal vs. deadline": the engine records the deadline as its own checkpoint (computed once, so a replay reuses it rather than restarting the clock) and the wait as a second one, and resumes on whichever comes first. Note the determinism cost: a bounded wait consumes two logical positions (deadline + wait) where an unbounded one consumes one — so adding or removing { timeoutMs } on an existing waitForSignal shifts every later step and should be treated as a versioned change for runs already in flight.

Don't build the race with Promise.race

Don't reach for Promise.race([ctx.sleep(...), ctx.waitForSignal(...)]) to build "whichever happens first" by hand. Every suspending primitive resumes by throwing to unwind the run back to the engine — it isn't a promise that stays pending until the real-world event happens, so racing two of them doesn't compose the way ordinary I/O promises do. Use waitForSignal's own { timeoutMs }; it is the one built and checkpointed for this.

The same shape handles "let the customer cancel within N minutes, otherwise proceed":

try {
  await ctx.waitForSignal(`cancel:${order.id}`, { timeoutMs: 10 * 60 * 1000 })
  return { status: 'cancelled' } // a cancel signal arrived in time
} catch (err) {
  if (!(err instanceof SignalTimeoutError)) throw err
  await ctx.step(shipOrder, order) // window closed — proceed
  return { status: 'shipped' }
}

Signal buffering & signal-with-start

A signal sent before the run has reached its waitForSignal is buffered, not lost — the next waitForSignal for that token consumes it. Delivery is to exactly one waiter, never a broadcast. Buffered payloads are FIFO per token (on the Lucid store, rows in durable_buffered_signals, consumed oldest first) and have no TTL or eviction: a buffered signal stays in the store until a matching waitForSignal(token) consumes it, however long that takes. This holds even under the tight race where the sender and the waiter arrive at almost the same instant: delivery uses a take → buffer → re-check sandwich on the send side that pairs with a register → re-check on the wait side, so a waiter that registers in the sliver between a "nobody's waiting" miss and the buffer write is still resumed instead of both rows being stranded (the lost-wake window is closed).

A child run's completion travels to its parent as a signal on child:<childRunId>, and is buffered only when a live parent can still consume it. A root run (no parent), a parent that is gone, completed or cancelled, and a parent that already holds that outcome (its signal:child: checkpoint carries the same output, compared as JSON regardless of key order, or a folded ctx.all fan covers the child) all mean nothing is buffered. Rows buffered before that check, or for a parent that finished after the write, are deleted by a bounded background sweep on each worker (500 rows a page, a few pages a pass, then a rest of ten minutes); a row whose parent cannot be read is kept. Each page costs a few statements whatever its size: one read of the parents' run rows, then one read by primary key of each live parent's checkpoints at the seqs the child ids name. A call stops after 250 ms and the next resumes where it stopped, and the worker loop runs it beside the tick rather than inside it, so a slow sweep never delays picking up runs, timers or schedules. The same keyed read decides, on the write path, whether a completion needs buffering. A parent that joins children which already finished settles them from their run rows in the same turn.

And engine.signalWithStart(workflow, input, runId, { token, payload }, opts?) starts a run if it doesn't exist and delivers a signal to it in one race-free call, resolving to { runId }. It does two things in order: start the workflow at runId (idempotent — a no-op if the run already exists), then signal token. Because signals buffer, the order in which concurrent callers arrive doesn't matter. This is the durable-entity / accumulator pattern: one long-lived run per key looping on waitForSignal, fed by many callers that don't need to know whether the entity is already running:

// Each event starts the run if needed, then delivers; ordering and exactly-once are preserved.
await engine.signalWithStart('counter', {}, 'k1', { token: 'add:k1', payload: 10 })
await engine.signalWithStart('counter', {}, 'k1', { token: 'add:k1', payload: 20 })

Writing a custom StateStore? Reliable delivery needs the store to expose removeSignalWaiter(waiter) — an exact-match delete (token + runId + seq must all match) that a waiter uses to retract its own registration after resolving some other way (a buffered hit, a timeout). It is distinct from takeSignalWaiter(token), which deletes any row for the token; a blind take there could steal a different run's waiter that has since claimed the same token. The bundled Lucid and in-memory stores already implement it.

Named events

A signal is point-to-point: one token, one waiter. A named event is name-based pub/sub. A single engine.publishEvent(name, payload) resumes every run parked on ctx.waitForEvent(name, { match }) whose match the payload satisfies, and starts a fresh run of every workflow subscribed with onEvent: [name]. Use it when several runs, or none yet, care about the same real-world fact.

// Each order run waits only for ITS payment to settle:
const settled = await ctx.waitForEvent<{ orderId: string; amount: number }>(
  'payment.settled',
  { match: { orderId: order.id }, timeoutMs: 24 * 60 * 60 * 1000 },
)

// Elsewhere, a controller or webhook handler:
await engine.publishEvent('payment.settled', { orderId, amount })

waitForEvent suspends with no compute, exactly like waitForSignal, and a timeoutMs that passes first throws SignalTimeoutError. Idempotent publishing (opts.id), debounce/batch coalescing of bursty subscribers (eventBatch), the match semantics, and how events compare with signals and updates are covered in the Events guide.

Reliable (buffered) events

A publish that reaches no live recipient (no waitForEvent matched and no onEvent subscriber exists) is buffered rather than dropped. One copy is kept, and the first future ctx.waitForEvent(name, { match }) that accepts its payload consumes it. Unlike a buffered signal, a buffered event can be given an expiry with eventBufferTtl in config/durable.ts. This is the events equivalent of signal buffering, and it handles the publish/wait race in both directions. Pass { buffer: false } to publishEvent for a live-only broadcast. On the Lucid store, the copies live in the durable_buffered_events table. See Reliable (buffered) delivery for the claim order and its limits.

Signals vs updates vs webhooks

All three suspend a run with zero compute until an external call resumes it, but they differ in scope and guarantees:

ScopeValidated before deliveryTypical use
Signal (ctx.waitForSignal / engine.signal)Global token you chooseNo — always lands, buffered if earlyPoint-to-point wakeup; the low-level primitive the other two build on
Update (ctx.onUpdate / engine.update)Run-scoped nameYes — an optional validator can reject before touching the runSteering a run from outside with a rejectable business rule
Webhook (ctx.webhook() / hook.wait())Deterministic per-call token (wh:<runId>:<seq>), with a public URLNo — a raw signal under the hoodThird-party async callbacks

An update is a signal with a validator in front (rejection happens in the caller's request, before the run ever wakes) and a run-scoped name instead of a global token. A webhook is a signal with the token and callback URL minted for you. The dashboard's POST <path>/api/webhooks/:token route, or a route of your own in start/routes.ts, turns the third party's POST into engine.signal(token, body); see Delivering the callback.

For a validated, rejectable steering primitive (Temporal-style updates) and a side-effect-free way to read a live run's state, see Queries & updates. For the deterministic callback-URL pattern, see Durable webhooks.

Patterns

Human approval with a visible status. Publish a queryable status with ctx.setEvent before parking, so a controller can show "awaiting approval" through engine.getEvent(runId, 'status'), and let the timeout fall through to an expiry branch:

await ctx.setEvent('status', 'awaiting-approval')
try {
  const decision = await ctx.waitForSignal<{ approved: boolean }>(`approve:${order.id}`, {
    timeoutMs: 3 * 24 * 60 * 60 * 1000, // 3 days
  })
  await ctx.setEvent('status', decision.approved ? 'approved' : 'rejected')
  return decision
} catch (err) {
  if (!(err instanceof SignalTimeoutError)) throw err
  await ctx.setEvent('status', 'expired')
  return { approved: false }
}

If the caller must get a synchronous rejection for a bad decision, not just a run that ignores it, use ctx.onUpdate with a validator instead (see Updates).

One long-lived run per key. engine.signalWithStart feeding a run that loops on waitForSignal is the accumulator shown above. For the packaged version, with per-key state and request/reply operations, see Entities. Bound its history with ctx.continueAsNew.

On this page

Partner

Your product as the official integration for Durable, right here.

Become a partner →