Skip to main content
Steps are how a workflow does durable work. Each step runs once, its result is saved, and it becomes a checkpoint the workflow can resume from. Wrap every unit of real work in a step. Every step takes a unique id as its first argument; Duraton keys the saved result by it. The console renders a run’s steps three ways - a list, a flow graph, and a timeline on a shared time axis, where a long sleep or waitForEvent shows as a gap and parallel steps overlap.
A run's steps as a flow graph

step.run

Run a function once and remember its result.
The first time, the function runs and its return value is saved. On any later pass, step.run returns the saved value without running the function again. The return value is whatever your function returns (it must be JSON-serializable, since it’s stored). Put anything with a side effect or a changing result inside a step.run - API calls, database writes, payments, reading the clock. See Durable execution for why.

Recording a step’s input

Pass an explicit input to record it on the step, so it shows on the console’s Input tab. The same value is also handed to the function:
The recorded input shows on the step’s Input tab in the console. It is optional: the bare step.run(id, fn) form captures no input (its arguments live in the function’s closure, which Duraton cannot see). The structural steps below record their input automatically - a runWorkflow’s child input, an emit’s payload, a waitForEvent’s event and timeout, a sleep’s duration - so the Input tab is backed wherever a step has a meaningful input.

step.skip

Record a step you deliberately bypassed. Without it, a conditionally-omitted step simply never appears, so a stage you chose not to run is indistinguishable from one that never existed. step.skip records a terminal skipped step - with an optional reason - so the bypass is visible in the run’s steps, the flow graph, and the timeline.
The reason is stored as the step’s output. step.skip is durable and replay-safe (the id must be stable across replays), and is available in the TypeScript SDK.

step.sleep

Pause the workflow for a duration. The wait is durable: the process can restart during it and the run still wakes up on time.
The duration is a string like "10s", "5m", "1h", or a number of milliseconds. Sleeps can be short or span days - Duraton owns the schedule, so nothing has to stay running in the meantime.

step.sleepUntil

Pause until an absolute instant rather than for a relative duration. Use it when the wake time is a fixed wall-clock target - midnight, a billing date, a scheduled send.
Reach for sleep when you mean “wait this long” and sleepUntil when you mean “wait until this moment.” Computing target - Date.now() to fake an absolute wait is wrong: it reads the clock outside a step. A target already in the past wakes immediately.

step.waitForEvent

Pause until a named event arrives, or until the timeout elapses. Returns the event’s data on arrival, or null on timeout.
An incoming event resumes every run waiting on that name. An event that arrives shortly before the run parks still wakes it: on parking, the step also looks back over recently-received events and resumes immediately if a matching one already arrived. This closes the race where a fast responder emits its event before the waiting run reaches its waitForEvent, so a request/response pattern never waits out its full timeout just because the reply came back first. The look-back is bounded: only events received within a short window before the park - 60 seconds - and never older than the run itself are considered, and the same if predicate below still applies, so a stale or unrelated event of the same name never wakes the wrong run. The window is fixed platform-wide and is not a per-call, per-workflow, or per-project knob.

Correlated waits

Use if to wait for the event that belongs to this run, rather than any event of that name. The predicate uses the same CEL dialect as event trigger filters - event.name and event.data are in scope:
Without if, correlating a wait to a specific entity forces the id into the event name (payment.settled.<ticketId>), which explodes event-name cardinality. The filter keeps one stable event name and matches on the payload instead.

step.poll

Re-check an external resource until it is ready. A resource that is still provisioning is not a failure - it is a normal intermediate state - so step.poll re-checks on a fixed interval without spending the step’s retry budget, and gives up after an overall deadline. Pass a probe that reads the resource and returns its value once ready, or a “not ready yet” signal otherwise. Between checks the run is suspended durably, exactly like step.sleep - it holds no worker and survives a restart. On the first ready check step.poll resolves with the value.
Each check is two durable steps, not a free suspension: a step.run probe call plus a step.sleep gap. A short every against a long timeout produces one checkpoint pair per interval - for example every: "5s" over a timeout: "10m" is up to 120 checks, so 240 durable steps. Prefer the widest every the resource’s provisioning time tolerates.
A probe that returns a value (or one that satisfies until) means ready. A probe that returns null/undefined (or one until rejects) means not ready: the run waits every and checks again. A probe that throws is a real error, not a “not ready” signal: it retries under the normal step retry policy and, if it exhausts its attempts, fails the run. Readiness (a “not ready” return) and failure (a throw) stay fully separate, so waiting for a resource never consumes the retry budget reserved for genuine errors.

When the deadline passes

If the resource is still not ready when timeout elapses, step.poll throws PollTimeoutError and the run fails, routing to onFailure if one is declared. Giving up on a required resource is a genuine failure, so this is the default. To treat “not ready in time” as a normal branch rather than a failure, catch it:

Poll vs retry

Polling and retrying look similar but answer different questions. A retry handles a step that failed - it re-runs the same work after a backoff and spends an attempt from the step’s budget each time; RetryAfterError only changes when that next attempt runs, and the run still fails once the budget is exhausted. A poll handles a resource that has not become ready yet - each check is a successful read, so it never spends the retry budget, and the wait is bounded by a wall-clock deadline instead of an attempt count. Reach for retry when a call can fail transiently; reach for poll when a call succeeds but the answer is “not yet.”

step.runWorkflow

Invoke another workflow as a child run and wait for its result. The parent blocks until the child reaches a terminal state; if the child fails, the failure cascades to the parent.
When two apps define a workflow with the same name, set app to target one exactly:

step.emit

Publish an event from inside a run. It can trigger other workflows or resume waitForEvent steps.

Step ids

The first argument to every step is its id ("triage", "wait-for-settlement"). The id is how Duraton matches a step to its saved result across passes, so:
  • Keep ids stable across replays - don’t compute them from changing values like timestamps, random values, or array contents. An id that changes between passes won’t match the work already done, so the step runs again. This also bites when you rename a step in a new deploy while runs are in flight - see changing step ids across deploys.
  • Give distinct work distinct ids. Two different steps that happen to share an id would resolve to the same saved result.

Reusing an id (loops)

Reusing the same id is legitimate and expected - a step inside a loop runs once per iteration under one id, and that is not an error. The SDK disambiguates repeats positionally, in execution order: the first occurrence of an id keeps it bare and each later occurrence gets a numeric suffix - fetch-page, then fetch-page:1, fetch-page:2, and so on. Each occurrence is its own durable step with its own saved result.
Because the suffix is assigned by execution order, the loop must be replay-deterministic: it has to run the same iterations in the same order on every pass, or the suffixes shift and later occurrences stop matching their saved results. Drive the loop from already-durable data - the event payload or a prior step’s result - not from a live source that could return a different set on the next pass.

Ordering

Steps run top to bottom, one after another. Each await completes before the next step begins, which is what lets a workflow resume at exactly the right place.
If this run is interrupted after triage, it resumes at the sleep; after the sleep, it resumes at refund. Completed steps are never repeated.

Parallel steps

Run independent steps concurrently with Promise.all. Duraton discovers the whole batch in one pass and runs the branches together instead of one per round trip.
Each branch is still its own durable step with its own id and saved result. The workflow continues past the Promise.all only after every branch has completed - the join. Branches can mix step kinds; a parallel step.sleep or step.waitForEvent parks alongside the others, and the run wakes as each deadline arrives. If one branch fails after exhausting its retries, the run fails (the same as Promise.all rejecting) and its still-running sibling steps are cancelled - nothing is left dangling. Use Promise.allSettled instead when you want every branch to finish regardless. One caveat: batching is best-effort. A branch that does its own await (an un-stepped fetch, say) before calling its step.run may be discovered on the next pass rather than with its siblings. It still runs correctly - it costs an extra round trip. Call your steps directly inside the Promise.all to keep them in one batch.