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}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:
| Scope | Validated before delivery | Typical use | |
|---|---|---|---|
Signal (ctx.waitForSignal / engine.signal) | Global token you choose | No — always lands, buffered if early | Point-to-point wakeup; the low-level primitive the other two build on |
Update (ctx.onUpdate / engine.update) | Run-scoped name | Yes — an optional validator can reject before touching the run | Steering 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 URL | No — same as a raw signal under the hood | Third-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).
Workflows & steps
Declaring workflows with @Workflow, the one dispatched ctx.step primitive and its @Step handlers, retries and backoff, fan-out, ctx.continueAsNew for long-running loops, fatal errors, sub-process events, step interceptors, tags, and search attributes.
Tenancy
What namespace and partition actually partition, how a store-less tenant borrows the control plane's store over the transport, and the boundary that keeps each tenant to its own runs.