- Building 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
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}Key behaviors:
- Results are persisted to the database
- On workflow restart, cached results are returned
- Step names must be unique within a function
- Return value is JSON-serializable
- 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. It overrides the function’sstepTimeout/StepTimeout; exceeding it raisesStepTimeoutError, which is retryable
step.sleep(name, duration) — Pause execution
Pauses the workflow for a specified duration. The pause is durable—if the server restarts, the workflow resumes after the remaining time.
await step.sleep("wait-24h", "24h"); // "1h", "30m", "7d"Duration formats:
"30s"— 30 seconds"5m"— 5 minutes"2h"— 2 hours"7d"— 7 days
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
step.sleepUntil(name, until) — Pause until a specific time
Pauses the workflow until a specific date/time. Like step.sleep, the pause is durable and survives server restarts.
// 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.toISOString());target := time.Now().Add(24 * time.Hour) // 24h from nowif err := ironflow.SleepUntil(ctx, "wait-24h", target); err != nil { return nil, err}Key behaviors:
- Accepts an ISO 8601 string (TypeScript) or
time.Time(Go) - TS SDK throws synchronously if the target is in the past; Go SDK currently lacks this check — caller is responsible
- Results are memoized like all other steps
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: 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; the HTTP
yield field is match_value.
Pauses the workflow until a matching event arrives or the timeout expires.
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)Options:
| Option | Type | Required | Description |
|---|---|---|---|
event | string | yes | Event name to wait for |
match | string | no | Field path to correlate events (e.g., "data.orderId") |
timeout | TS string | number (ms) / Go time.Duration | no | Maximum time to wait (default "7d") |
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)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. - 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
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}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
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", ...); // Error: duplicate nameGuidelines:
- 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
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 only and skips unchanged registrations. Replacing handler code while keeping configuration identical can retain the version. 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
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 paused at waits 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
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).step.publish— publish to a pub/sub topic from inside a step (memoized, so it publishes exactly once even on retry). It does not trigger functions — use the events API for that.
What’s Next?
- Execution Modes — Learn about push and pull modes
- Error Handling — Handle errors with NonRetryableError