- Workflows
- Step Primitives
Step Primitives
Steps are the building blocks of workflows. Each step is memoized—if a workflow restarts, completed steps aren’t re-executed. This provides effectively-once execution via idempotent memoization (NATS JetStream is at-least-once; memoization makes step results stable across retries).
step.run(name, fn) — Execute and memoize
Section titled “step.run(name, fn) — Execute and memoize”Executes a function and caches the result. If the workflow restarts, the cached result is returned instead of re-executing.
const user = await step.run("fetch-user", async () => { return await db.users.find(event.data.userId);});var d OrderEventif err := ctx.Event.Data(&d); err != nil { return nil, err}user, err := ironflow.Run(ctx, "fetch-user", func() (User, error) { return db.Users.Find(d.UserID)})if err != nil { return nil, err}user = await ctx.step.run("fetch-user", lambda: db.users.find(ctx.event.data["userId"]))A sync callback runs in a thread. An async def callback is awaited.
Key behaviors:
- Results are persisted to the database
- On workflow restart, cached results are returned
- Keep step names unique within a function (see Step Naming Best Practices)
- Return value is JSON-serializable
- A step is durable once the engine has recorded it. A crash before that runs the step again: for a pull worker, within one checkpoint interval (default 1 second) of the step finishing; for push mode, before the handler returns or yields. Make step bodies idempotent
- Takes an optional third argument to cap this step’s wall-clock time —
step.run(name, fn, { timeout: "30s" })in TypeScript,ironflow.Run(ctx, name, fn, ironflow.WithTimeout(30*time.Second))in Go,ctx.step.run(name, fn, timeout="30s")in Python. It overrides the function’sstepTimeout/StepTimeout; exceeding it raisesStepTimeoutError, which is retryable
step.sleep(name, duration) — Pause execution
Section titled “step.sleep(name, duration) — Pause execution”Waits durably for a specified duration. The run reports waiting and resumes after the remaining time across server restarts. An operator hold reports paused; explicit Resume preserves the original wake time.
await step.sleep("wait-24h", "24h"); // "1h", "30m", "7d"Duration formats:
"500ms"— 500 milliseconds"30s"— 30 seconds"5m"— 5 minutes"2h"— 2 hours"7d"— 7 days5000— a bare number is milliseconds
if err := ironflow.Sleep(ctx, "wait-24h", 24*time.Hour); err != nil { return nil, err}Duration examples:
30 * time.Second5 * time.Minute2 * time.Hour7 * 24 * time.Hour— 7 days
await ctx.step.sleep("wait-24h", "24h")Duration examples: "30s", "5m", "2h", "7d", timedelta(hours=2), or a
bare number of seconds (30).
step.sleepUntil(name, until) — Pause until a specific time
Section titled “step.sleepUntil(name, until) — Pause until a specific time”Waits until a specific date/time. Like step.sleep, the run reports waiting and the original deadline survives server restarts and operator hold/resume.
// Sleep until a specific ISO 8601 timestamp (must be in the future)const target = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(); // 24h from nowawait step.sleepUntil("wait-24h", target);
// Or pass a Date objectconst tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000);await step.sleepUntil("wait-until-tomorrow", tomorrow);target := time.Now().Add(24 * time.Hour) // 24h from nowif err := ironflow.SleepUntil(ctx, "wait-24h", target); err != nil { return nil, err}from datetime import datetime, timedelta, timezone
target = datetime.now(timezone.utc) + timedelta(hours=24) # 24h from nowawait ctx.step.sleep_until("wait-24h", target)Key behaviors:
- Accepts a
Dateor an ISO 8601 string (TypeScript),time.Time(Go), or a timezone-awaredatetimeor ISO string (Python) - The TS and Python SDKs throw if the target is in the past; the Go SDK currently lacks this check — caller is responsible
- Results are memoized like all other steps
step.waitForEvent(name, filter) — Wait for correlated event
Section titled “step.waitForEvent(name, filter) — Wait for correlated event”match is a dot-separated JSON path, not a comparison expression. Paths accept
letters, digits, underscores, and hyphens in each segment, with optional data.
or $. prefixes. Without matchValue, the engine snapshots the value at that
path in the triggering event. A missing or null trigger value fails the yield;
a missing or null incoming value never matches.
Use a non-empty matchValue to correlate against a literal that is absent from
the triggering event, such as the current run ID:
const approval = await step.waitForEvent("approval", { event: "approval.received", match: "data.runId", matchValue: run.id, // `run` comes from the handler context, beside `event` and `step` timeout: "1h",});approval = await ctx.step.wait_for_event( "approval", event="approval.received", match="data.runId", match_value=ctx.run.id, timeout="1h",)matchValue requires match. An omitted or empty value uses the triggering
event. The Go SDK exposes the same option as EventFilter.MatchValue, and Python as
match_value=; the HTTP
yield field is match_value.
Pauses the workflow until a matching event arrives or the timeout expires.
A matching event can arrive before the workflow reaches this step. When the step parks, Ironflow checks retained events in the same environment, stored since the run was created and no later than the wait’s deadline. This check uses server storage time, not the event’s publisher-supplied timestamp, and examines the oldest eligible events first. The scheduler retries the check after a restart and before timing out the step.
The event that a wait returns carries the authenticated emitter that the engine
recorded: emittedBy in TypeScript, EmittedBy in Go and emitted_by in Python.
Examples are apikey:<id>, user:<id>, webhook:<source-id>, nats and
platform. The engine sets it from the credential of the caller, and the event
body cannot change it. The value is empty when the engine reports none.
approve() returns the same value beside approver. approver is free text from
the event data, and the caller chooses it. An operator with runs:cancel can inject or patch a step output, so emittedBy proves the emitter only for a step that an event resolved. This follows ADR 0129.
An event already consumed by an earlier completed wait in the same run cannot
satisfy a later gate. Neither can an event with the same name that was stored
before an earlier completed wait for that name ended, so a duplicate reply sent
before the first gate closed is not reused. The event that started the run
cannot satisfy its own wait. Events stored before the run was created, or
removed by retention, cannot satisfy this history check. approve() uses the same behavior,
with the run ID as its correlation value, so an early approval does not need
to be resent.
const approval = await step.waitForEvent("wait-approval", { event: "order.approved", match: "data.orderId", // matches event.data.orderId timeout: "7d",});
// The handler only resumes when a matching event arrives.// If the timeout expires first, the run is failed server-side with// "waitForEvent timed out" — this line is never reached on timeout.console.log("Approved by:", approval.data.approvedBy);approval, err := ironflow.WaitForEvent[any](ctx, "wait-approval", ironflow.EventFilter{ Event: "order.approved", Match: "data.orderId", // matches event.data.orderId Timeout: 7 * 24 * time.Hour,})if err != nil { return nil, err}
// The handler only resumes when a matching event arrives. If the timeout// expires first, the run is failed server-side with "waitForEvent timed out"// — this code is never reached on timeout.var data struct { ApprovedBy string `json:"approvedBy"`}approval.Data(&data)fmt.Printf("Approved by: %s\n", data.ApprovedBy)approval = await ctx.step.wait_for_event( "wait-approval", event="order.approved", match="data.orderId", # matches event.data.orderId timeout="7d",)
# The handler only resumes when a matching event arrives. If the timeout# expires first, the run is failed server-side — this code is never reached.print(f"Approved by: {approval.data['approvedBy']}")Branch on a wait timeout
Section titled “Branch on a wait timeout”By default, a wait deadline fails the run. To branch on absence, opt in to a
completed, memoized null result. The engine still checks accepted matching
events through the deadline before settling the timeout. The policy and result
survive restarts; replay returns the same absence without waiting again.
Live delivery and replay use server storage time: a reply accepted after the
deadline cannot satisfy the wait, even if its publisher timestamp is earlier.
A reply accepted on time remains eligible when delivery runs late.
If the platform cannot read event history, it defers timeout until two minutes
after the persisted deadline. This window starts at the deadline, so a first
failed read after a long restart can fail immediately. If replay still fails, the run fails with
waitForEvent timeout outcome unknown: event history replay failed after grace period;
it does not return null and guess that no reply arrived.
const reply = await step.waitForEvent("confirm-seat", { event: "seat.confirmed", timeout: "5m", onTimeout: "return-null",});if (reply === null) { await step.run("release-seat", async () => releaseSeat());}The default return type is IronflowEvent<T>; onTimeout: "return-null" makes
it IronflowEvent<T> | null.
reply, err := ironflow.WaitForEventOptional(ctx, "confirm-seat", ironflow.EventFilter{ Event: "seat.confirmed", Timeout: 5 * time.Minute,})if err != nil { return nil, err }if reply == nil { return releaseSeat() }WaitForEventOptional returns (*Event, error). Use
WaitForEventOptionalWithBranch inside a parallel branch. Existing
WaitForEvent and WaitForEventWithBranch keep their fail-on-timeout behavior.
reply = await ctx.step.wait_for_event( "confirm-seat", event="seat.confirmed", timeout="5m", on_timeout="return-null",)if reply is None: await ctx.step.run("release-seat", release_seat)The default return type is Event; the opt-in return type is Event | None.
approve() keeps its fail-on-timeout default.
JavaScript yielding steps (sleep, sleepUntil, waitForEvent, invoke, and
invokeAsync) suspend execution with an internal signal. Let that signal
propagate. If a handler or parallel branch catches it and returns, or tries
another durable operation, the SDK fails the run nonretryably with a diagnostic.
The failure remains fatal even if an outer catch hides that diagnostic.
Put ordinary error handling inside the step.run callback. This guard cannot
prevent arbitrary network calls or other side effects performed directly inside
a catch block before the SDK regains control. Python’s suspension signal derives
from BaseException; do not catch it with except BaseException. Go uses a
panic; do not recover it in the handler.
Options:
| Option | Type | Required | Description |
|---|---|---|---|
event | string | yes | Event name to wait for |
onTimeout (TS) / on_timeout (Python) | "fail" | "return-null" | no | Fail the run by default; opt in to a completed null result. In Go use WaitForEventOptional. |
match | string | no | Field path to correlate events (e.g., "data.orderId") |
matchValue (TS) / MatchValue (Go) / match_value (Python) | string | no | Non-empty literal compared at match. Omitted or empty snapshots the triggering event. Requires match. |
payload (TS, Python) / Payload (Go) | unknown / any | no | Request data stored as the waiting step’s input — visible via getRunSteps and in the dashboard, so an approver can see what they are approving |
timeout | TS string | number (ms) / Go time.Duration / Python duration ("7d", timedelta, or seconds) | no | Maximum time to wait (default "7d") |
step.parallel(name, branches) — Execute branches concurrently
Section titled “step.parallel(name, branches) — Execute branches concurrently”Run multiple operations in parallel with isolated step contexts:
const [userResult, orderResult, inventoryResult] = await step.parallel( "fetch-all-data", [ async (s) => s.run("fetch-user", async () => fetchUser(userId)), async (s) => s.run("fetch-order", async () => fetchOrder(orderId)), async (s) => s.run("check-inventory", async () => checkInventory(productId)), ], { concurrency: 2, // Max 2 branches running at once onError: "failFast", // Stop on first error (default) or "allSettled" });// Assume User, Order, Inventory types are defined elsewhere.results, err := ironflow.Parallel(ctx, "fetch-all-data", []func(*ironflow.BranchContext) (any, error){ func(b *ironflow.BranchContext) (any, error) { return ironflow.RunWithBranch(b, "fetch-user", func() (User, error) { return fetchUser(userID) }) }, func(b *ironflow.BranchContext) (any, error) { return ironflow.RunWithBranch(b, "fetch-order", func() (Order, error) { return fetchOrder(orderID) }) }, func(b *ironflow.BranchContext) (any, error) { return ironflow.RunWithBranch(b, "check-inventory", func() (Inventory, error) { return checkInventory(productID) }) }, }, ironflow.ParallelOptions{ Concurrency: 2, // Max 2 branches running at once OnError: "failFast", // "failFast" (default) or "allSettled" },)if err != nil { return nil, err}
user := results[0].(User)order := results[1].(Order)inventory := results[2].(Inventory)results = await ctx.step.parallel( "fetch-all-data", [ lambda b: b.run("fetch-user", lambda: fetch_user(user_id)), lambda b: b.run("fetch-order", lambda: fetch_order(order_id)), lambda b: b.run("check-inventory", lambda: check_inventory(product_id)), ], concurrency=2, # Max 2 branches running at once on_error="fail_fast", # "fail_fast" (default) or "collect")
user, order, inventory = resultsEach branch receives its own scoped Step. With on_error="collect", a failed
branch’s slot holds its exception instead of a result.
Key behaviors:
- Each branch receives a scoped step client (TypeScript) or
*BranchContext(Go). It is load-bearing: a branch is not itself a recorded step. Work done directly in the callback body persists nothing and re-runs in full on every retry; work routed through the enclosing function’s step client is memoized outside the branch’s scope. Both compile, so the SDK warns on either shape: a step claimed on the enclosing client while the fan-out is running (its index comes from a counter shared by every branch, so a resume can cross the wires), or every branch skipping the scope. See the opt-out in the options table below. TypeScript warns on the second shape only. The Python SDK raises neither warning. - Results are returned in order regardless of completion order
- Supports concurrency limits to control parallelism
- Two error modes:
failFast(stop immediately) orallSettled(complete all)
Options:
| Option | Type | Default | Description |
|---|---|---|---|
concurrency | number | unlimited | Maximum concurrent branches |
onError | "failFast" | "allSettled" | "failFast" | Error handling strategy |
expectScopedClient (TypeScript) | boolean | true | Set false to silence the unscoped-branch warning |
SkipScopedClientCheck (Go) | bool | false | Set true to silence the unscoped-branch warning |
The two SDKs spell the opt-out with opposite names and opposite polarity, and
both default to warning. Go struct fields zero-value to false, so an
ExpectScopedClient bool would have defaulted every existing caller into the
opt-out.
step.map(name, items, fn) — Fan-out/fan-in pattern
Section titled “step.map(name, items, fn) — Fan-out/fan-in pattern”Process an array of items in parallel:
const userIds = ["user_1", "user_2", "user_3"];const users = await step.map( "fetch-all-users", userIds, async (userId, s, index) => { return await s.run(`fetch-user-${index}`, async () => { return await fetchUserDetails(userId); }); }, { concurrency: 5, // Max 5 concurrent operations onError: "allSettled", // Complete all items even if some fail });userIDs := []string{"user_1", "user_2", "user_3"}users, err := ironflow.Map(ctx, "fetch-all-users", userIDs, func(userID string, b *ironflow.BranchContext, index int) (User, error) { return ironflow.RunWithBranch(b, fmt.Sprintf("fetch-user-%d", index), func() (User, error) { return fetchUserDetails(userID) }) }, ironflow.ParallelOptions{ Concurrency: 5, // Max 5 concurrent operations OnError: "allSettled", // Complete all items even if some fail },)if err != nil { return nil, err}user_ids = ["user_1", "user_2", "user_3"]users = await ctx.step.map( "fetch-all-users", user_ids, lambda user_id, b, index: b.run(f"fetch-user-{index}", lambda: fetch_user_details(user_id)), concurrency=5, # Max 5 concurrent operations on_error="collect", # Complete all items even if some fail)Use cases:
- Processing multiple orders simultaneously
- Fetching data from multiple sources
- Sending notifications to multiple users
- Batch processing with controlled parallelism
Options:
| Option | Type | Default | Description |
|---|---|---|---|
concurrency | number | unlimited | Maximum concurrent operations |
onError | "failFast" | "allSettled" | "failFast" | Error handling strategy |
expectScopedClient (TypeScript) | boolean | true | Set false to silence the unscoped-branch warning |
SkipScopedClientCheck (Go) | bool | false | Set true to silence the unscoped-branch warning |
The two SDKs spell the opt-out with opposite names and opposite polarity, and
both default to warning. Go struct fields zero-value to false, so an
ExpectScopedClient bool would have defaulted every existing caller into the
opt-out.
Step Naming Best Practices
Section titled “Step Naming Best Practices”Step names must be unique within a function and should be descriptive:
// Good: descriptive, unique namesawait step.run("validate-order-items", ...);await step.run("calculate-shipping-cost", ...);await step.run("process-payment-stripe", ...);
// Bad: generic or duplicate namesawait step.run("step1", ...);await step.run("process", ...);await step.run("process", ...); // Allowed: the SDK adds a counter to the step ID, but the step list is hard to read# Good: descriptive, unique namesawait ctx.step.run("validate-order-items", validate_items)await ctx.step.run("calculate-shipping-cost", calculate_shipping)await ctx.step.run("process-payment-stripe", charge)// Good: descriptive, unique namesironflow.Run(ctx, "validate-order-items", validateItems)ironflow.Run(ctx, "calculate-shipping-cost", calculateShipping)ironflow.Run(ctx, "process-payment-stripe", charge)No SDK rejects a repeated name: each one adds a per-name counter to the step ID. Keep names unique anyway, so the step list stays readable.
Guidelines:
- Use kebab-case for consistency
- Include the action and target (e.g.,
fetch-user,send-email) - Make names unique even in loops (e.g.,
process-item-${index})
Deploying changes to workflows with existing runs
Section titled “Deploying changes to workflows with existing runs”A resumed handler encounters persisted outputs from completed steps. New code must still understand those outputs and preserve the meaning of unfinished work.
| Version | What it identifies today |
|---|---|
| Configuration/history versions | Registered function settings and snapshots in ironflow:fn:{id}. The configuration counter and history entity version are distinct fields. |
| Executable deployment identity | The actual handler build or artifact. Ironflow does not currently pin runs to an immutable executable identity. |
| Event schema version | The shape of an event payload. Event upcasters adapt events, not completed step results. |
| Run’s recorded version | runs.function_version snapshots the function’s EntityVersion at run creation. It is not a handler code digest. |
Registration compares configuration and metadata, and skips unchanged registrations. The Go, Node and Python SDKs stamp a code hash into the function metadata (__ironflow_code_hash), so editing a handler normally registers a new version. Go hashes the running binary. Node and Python hash the handler’s own source, so a change in a helper, a dependency or a captured value can leave the hash, and the version, unchanged. A registration without that metadata, such as a plain RegisterFunction call, retains the version when only the code changes. Pull worker capabilities read the live function record’s entity version; matching that version to a queued run does not prove that the worker has the original executable. Push dispatch also resolves the live function configuration, including its endpoint. These are current source-backed limitations, not a guarantee of executable pinning.
Safe and unsafe changes
Section titled “Safe and unsafe changes”A compatible change preserves step identity and ordering, accepts existing output schemas, and preserves the semantics of branches, waits, and side effects for existing runs. Validate that compatibility using representative persisted outputs and runs waiting at durable steps before deploying.
Treat these changes as incompatible until you have checked existing runs:
- Renaming, reordering, inserting, removing, or changing the type of a step can change which persisted result is reused or cause work to execute again.
- Changing an output schema can leave new code reading an old cached shape. Editing the completed step’s callback does not recompute its stored output.
- Changing branching or wait names, filters, or timeouts can alter the path taken after resume or conflict with a wait already registered by the old code.
- Changing side-effect semantics can mix an old completed action with new unfinished actions. Renaming a payment step to force execution can charge again.
For incompatible changes, stop new admissions to the old function and drain outstanding runs, including sleeping, waiting, retryable, and queued runs, before replacing its handler. If both implementations must coexist, use a separate function ID for new work and retain the old handler and endpoint or workers for the old ID. Route new triggers deliberately so both functions do not consume the same work accidentally. Keep side effects idempotent across retries.
Function registration history can restore configuration. It does not preserve or restore handler code, migrate completed step results, or make an incompatible deployment safe. Keep deployable artifacts under your own deployment management.
Immutable executable IDs and routing both push and pull runs to their originating deployment are accepted future work, not implemented behavior. The agreed direction is to keep runs pending with a visible reason when their executable is unavailable, with operator alerts and explicit cancellation instead of silently using newer code. Operators would initially retain old workers or version-specific endpoints. Artifact hosting and lifecycle management remain outside that first implementation. Track it in #2166; nothing on this page changes until it ships.
Other step primitives
Section titled “Other step primitives”Covered on neighbouring pages:
step.compensate— register an undo for a step; runs in reverse order on failure. See Sagas.step.invoke/step.invokeAsync— call another function from inside a step (sync awaits the result; async fire-and-forget). The target function must have no event triggers. In Go:ironflow.Invoke[T](ctx, id, input)andironflow.InvokeAsync(ctx, id, input). In Python:await ctx.step.invoke(id, input)andawait ctx.step.invoke_async(id, input). The default wait is 30 seconds.step.publish— publish to a pub/sub topic from inside a step. The step is memoized, so a retry after the engine recorded it does not publish again; for a retry before that point, the step sends a default idempotency key derived from its step ID, so the server drops the repeat. The server keeps the key forIRONFLOW_RUN_RETENTION_DAYSdays (forever when unset or 0) and returns the original event ID (ADR 0132). Pass your own key to replace the default. It does not trigger functions — use the events API for that. In Go:ironflow.Publish(ctx, topic, data). In Python:await ctx.step.publish(topic, data). Underserve(), setserver_url=orIRONFLOW_SERVER_URL; without it the run fails with “server URL not configured for publish step”.
What’s Next?
Section titled “What’s Next?”- Execution Modes — Learn about push and pull modes
- Error Handling — Handle errors with NonRetryableError