Aviary
Concepts

Sleep & signals

Pause a workflow durably — ctx.sleep for time-based waits (minutes to months, no compute) and ctx.waitForSignal for human approvals and webhooks, both 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');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. duration accepts milliseconds as a number, or a string like '500ms', '30s', '15m', '2h', '7d', '7 days', '2 weeks'. For a fixed deadline instead of a relative delay, use ctx.sleepUntil(when: Date | number): Promise<void>.

await ctx.step(this.digest.draft, topic);
await ctx.sleep('7 days'); // gather signals for a week — nothing running
await ctx.step(this.digest.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: the engine sweeps for due timers on a regular poll tick (default every second, timerPollMs) and resumes any run whose stored wake time has passed. There's no per-sleep timeout or setTimeout held in memory; a poller sweep is what notices the deadline has passed and resumes the run.

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 skew or a redeploy can't accidentally shift an already-scheduled timer.

Signals

ctx.waitForSignal<TPayload>(token: string, opts?: { timeoutMs?: number }): Promise<TPayload> suspends the run — with no timer by default — until an external engine.signal(token, payload) delivers a payload for that exact token. Good for approvals, third-party callbacks, or any point-to-point wakeup.

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

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. Two different runs waiting on the same literal string collide: the underlying waiter row is keyed by token alone (token is the primary key of durable_signal_waiters), so registering a second waiter for a token that's already waited-on replaces the first. Always embed the run's identity in the token (`approve:${order.id}`, not 'approve') so it's unique across the whole engine.

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

Sending a signal

Deliver a signal from anywhere — typically a controller — via engine.signal(token, payload) or WorkflowService.signal(token, payload):

@Post('orders/:id/approve')
approve(@Param('id') id: string, @Body() body: { approved: boolean }) {
  return this.workflows.signal(`approve:${id}`, body); // resumes the run with the payload
}

The payload is checkpointed like any step result, so the resumed run sees the same decision even if it replays again later.

Buffering — signals are never dropped. engine.signal first tries to find a waiter for token. If one is parked, it resolves that waiter's checkpoint and resumes the run — delivery is to exactly one waiter, never a broadcast. If nobody is waiting yet (the signal arrives before the run reaches its waitForSignal), the engine buffers the payload — FIFO per token, in a durable_buffered_signals table row — instead of dropping it. There is no TTL or eviction on a buffered signal: it sits in the store until the matching waitForSignal(token) call consumes it (oldest first), however long that takes. That buffering is what makes signalWithStart (below) race-free.

signalWithStart

engine.signalWithStart(
  workflow: WorkflowRef,
  input: unknown,
  runId: string,
  signal: { token: string; payload?: unknown },
  opts?: StartOptions,
): Promise<{ runId: string }>

Also exposed on WorkflowService.signalWithStart. It does two things in order: start the workflow at runId if no run exists yet (idempotent — a no-op if it's already running), then signal it at signal.token. Because signals buffer, this is race-free regardless of which caller gets there first: if the run is brand new and hasn't reached its waitForSignal yet, the signal buffers and is consumed the instant it does.

This is the durable-entity / accumulator pattern: one long-lived run per key (runId) looping on waitForSignal, fed by many signalWithStart calls from different callers over the run's lifetime — each caller doesn't need to know whether the entity is already running.

Timeouts

Pass { timeoutMs } to bound the wait: ctx.waitForSignal(token, { timeoutMs }). If the deadline passes before a signal (or a buffered one) arrives, the call throws SignalTimeoutError — catch it in the workflow to take a default branch.

try {
  const decision = await ctx.waitForSignal<Decision>(`approve:${order.id}`, {
    timeoutMs: 3 * 24 * 60 * 60 * 1000, // 3 days
  });
  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 sleep checkpoint and the wait as a second checkpoint, 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 the seq of every later step, and should be treated as a workflow-version change for runs already in flight.

A note on Promise.race: don't reach for Promise.race([ctx.sleep(...), ctx.waitForSignal(...)]) to build a "whichever happens first" race by hand. Every suspending primitive resumes by throwing WorkflowSuspended to unwind the whole 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 it would with ordinary I/O promises. Use waitForSignal's own { timeoutMs } for a signal-vs-timeout race — it's the one built and checkpointed for this.

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 (see Queries & updates)
Webhook (ctx.webhook() / hook.wait())Deterministic per-call token (wh:<runId>:<seq>), with a public URLNo — same as a raw signal under the hoodThird-party async callbacks (payment providers, etc. — see Durable webhooks)

An update is a signal with a validator bolted on the 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 a public callback URL minted for you — the dashboard's POST webhooks/:token turns the third party's HTTP callback into engine.signal(token, body). All three ultimately resume a WorkflowSuspended run the same way.

Patterns

Human approval. ctx.waitForSignal(`approve:${id}`, { timeoutMs }) parked behind a ctx.setEvent('status', ...) so a controller can show "awaiting approval", with the timeout falling through to an expiry branch — this is the shape used throughout this page. If you also need the caller to get a synchronous rejection reason for a bad decision (not just "the run ignored it"), reach for ctx.onUpdate with a validator instead (see Queries & updates).

Order-cancellation race (deadline vs. cancellation signal). The same { timeoutMs } 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) {
    await ctx.step(this.fulfilment.ship, order); // window closed — proceed
    return { status: 'shipped' };
  }
  throw err;
}

This is the built-in race — deadline vs. signal — resolved by the engine's own two-checkpoint bookkeeping, not by racing promises by hand (see the Promise.race note under Timeouts).

On this page