Skip to content

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);
});

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’s stepTimeout / StepTimeout; exceeding it raises StepTimeoutError, 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

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 now
await step.sleepUntil("wait-24h", target);
// Or pass a Date object
const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000);
await step.sleepUntil("wait-until-tomorrow", tomorrow.toISOString());

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);

Options:

OptionTypeRequiredDescription
eventstringyesEvent name to wait for
matchstringnoField path to correlate events (e.g., "data.orderId")
timeoutTS string | number (ms) / Go time.DurationnoMaximum 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"
}
);

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) or allSettled (complete all)

Options:

OptionTypeDefaultDescription
concurrencynumberunlimitedMaximum concurrent branches
onError"failFast" | "allSettled""failFast"Error handling strategy
expectScopedClient (TypeScript)booleantrueSet false to silence the unscoped-branch warning
SkipScopedClientCheck (Go)boolfalseSet 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
}
);

Use cases:

  • Processing multiple orders simultaneously
  • Fetching data from multiple sources
  • Sending notifications to multiple users
  • Batch processing with controlled parallelism

Options:

OptionTypeDefaultDescription
concurrencynumberunlimitedMaximum concurrent operations
onError"failFast" | "allSettled""failFast"Error handling strategy
expectScopedClient (TypeScript)booleantrueSet false to silence the unscoped-branch warning
SkipScopedClientCheck (Go)boolfalseSet 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 names
await step.run("validate-order-items", ...);
await step.run("calculate-shipping-cost", ...);
await step.run("process-payment-stripe", ...);
// Bad: generic or duplicate names
await step.run("step1", ...);
await step.run("process", ...);
await step.run("process", ...); // Error: duplicate name

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

A resumed handler encounters persisted outputs from completed steps. New code must still understand those outputs and preserve the meaning of unfinished work.

VersionWhat it identifies today
Configuration/history versionsRegistered function settings and snapshots in ironflow:fn:{id}. The configuration counter and history entity version are distinct fields.
Executable deployment identityThe actual handler build or artifact. Ironflow does not currently pin runs to an immutable executable identity.
Event schema versionThe shape of an event payload. Event upcasters adapt events, not completed step results.
Run’s recorded versionruns.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?