Skip to content
Ironflow is pre-1.0: APIs can change between releases. Pin a version. What is ready →

Common Pitfalls

AI assistants make predictable mistakes when generating Ironflow code. Learn to spot and fix them.


The Problem: AI generates step code that causes duplicate operations on retry.

await step.run("create-payment", async () => {
// No idempotency key - creates duplicate charges on retry!
return await stripe.charges.create({
amount: order.total,
currency: "usd",
});
});

How to Fix: Always use idempotency keys when calling external APIs. Most payment processors, email services, and APIs support idempotency keys.

Tell the AI:

Ensure all external API calls inside steps use idempotency keys.
For Stripe, use idempotencyKey. For other services, use appropriate
deduplication mechanisms.

The Problem: AI puts side effects (API calls, database queries) outside step.run, causing them to execute on every replay.

async ({ event, step }) => {
// BAD: This runs on EVERY replay, not just once
const user = await db.users.find(event.data.userId);
await step.run("process", async () => {
return processUser(user);
});
};

How to Fix: Every external call (database, API, file system) must be inside a step.run.

Tell the AI:

All side effects (database queries, API calls, file operations) must be
inside step.run. The function body outside steps should only contain
control flow logic using data returned from steps.

The Problem: AI reuses step IDs, especially in loops.

for (const item of items) {
// BAD: Same step ID for every item - only first executes
await step.run("process-item", async () => {
return processItem(item);
});
}

How to Fix: Use step.map for arrays. If using loops, include a unique identifier in the step ID.

Tell the AI:

Each step needs a unique ID. For arrays, use step.map instead of loops.
If you must use loops, include the index or item ID in the step name.

The Problem: AI doesn’t distinguish between retryable and non-retryable errors.

await step.run("validate", async () => {
if (!isValidEmail(email)) {
// BAD: Will retry forever, but email won't become valid
throw new Error("Invalid email");
}
});

How to Fix: Use NonRetryableError for validation failures and permanent errors. Let transient errors (network, timeout) retry normally.

Tell the AI:

Use NonRetryableError for:
- Validation failures
- Invalid input
- Business rule violations
- Resource not found
- Permission denied
Let these retry normally:
- Network timeouts
- Database connection errors
- Rate limiting (with backoff)

The Problem: AI treats waitForEvent timeout as something the workflow can catch and recover from inline. It cannot — the SDK return type is non-nullable (Promise<IronflowEvent<T>>), and the engine transitions the step to timed_out and fails the run with "waitForEvent timed out" (timeoutStep in internal/engine/scheduler.go). The code after the wait never runs.

Do not reach for try/catch either. waitForEvent suspends the run by throwing an internal YieldSignal that the SDK catches at the handler boundary (isYieldSignal in sdk/js/node/src/serve.ts). A user catch — or a chained .catch() — swallows that signal, so the run does not suspend at all: it returns as if it had finished and the wait silently never happens. The same applies to sleep, sleepUntil, invoke and invokeAsync. Only step.run throws ordinary catchable errors, because it invokes your callback inline. (In the Go SDK the yield is a panic, so a stray recover() does the same damage.)

// BAD: null check never fires — timeout fails the run instead
const approval = await step.waitForEvent("wait-approval", {
event: "order.approved",
match: "data.orderId",
timeout: "24h",
});
if (!approval) {
await step.run("cancel", async () => cancelOrder(orderId));
return { status: "cancelled" };
}
// ALSO BAD: the catch eats the YieldSignal — the run completes without waiting
try {
const approval = await step.waitForEvent("wait-approval", { ... });
} catch {
return { status: "cancelled" };
}

How to Fix: Pick the model that matches the business requirement:

  • Hard deadline is acceptable — let waitForEvent time out. The run fails with "waitForEvent timed out"; an external listener on system.run.*.failed triggers the recovery flow.
  • Soft deadline needs inline branching — arm a fallback event from a separate run (step.invokeAsync to a deadline function, or a sibling cron function) so the waitForEvent matches either the real event or the timeout event. The workflow keeps running on a single, well-typed result. Do not use step.sleep in the waiting handler for this — it suspends the very run that needs to be waiting.

Tell the AI:

waitForEvent does NOT return null on timeout. The return type is
Promise<IronflowEvent<T>>, and a timeout marks the step "timed_out"
and fails the run.
Never wrap waitForEvent (or sleep, sleepUntil, invoke, invokeAsync)
in try/catch or a .catch() chain. They suspend the run by throwing an
internal YieldSignal; catching it makes the run complete without ever
waiting. Only step.run throws catchable errors.
If the workflow needs to handle the timeout case inline, model the
timeout as its own event: arm a delayed emit in a SEPARATE run
(step.invokeAsync to a deadline function, or a cron sibling), then
waitForEvent on a unified "settled" event that either branch
publishes. Otherwise, let the run fail and react via a separate
listener on system.run.*.failed.

The Problem: AI uses push mode for long-running tasks or pull mode for quick tasks.

// BAD: Video transcoding in push mode will timeout
export const POST = serve({
functions: [transcodeVideo], // Takes 10+ minutes
});

How to Fix:

  • Push mode: Tasks under 10 seconds, serverless deployments
  • Pull mode: Long-running tasks, GPU workloads, no timeout limits

Tell the AI:

Use push mode for tasks < 10 seconds (serverless).
Use pull mode for:
- Video/audio processing
- ML inference
- Large file operations
- Any task that might exceed serverless timeouts

The Problem: AI uses a field path that is not in the payload. match is a path inside event.data. The engine strips an optional data. prefix, so match: "orderId" and match: "data.orderId" are equivalent. A key that the payload does not carry never matches. A value that differs between the triggering event and the awaited event never matches either.

// Original event: { data: { orderId: "123" } }
await step.waitForEvent("wait-payment", {
event: "payment.completed",
match: "id", // BAD: the payload carries "orderId", not "id"
timeout: "1h",
});

How to Fix: Use a path to a field that exists in both events, for example data.orderId.

Tell the AI:

The match field in waitForEvent is a path inside event.data (an optional
"data." prefix is accepted). Use a field that exists in both events, for
example "data.orderId".

The Problem: AI mixes promises incorrectly within steps.

await step.run("fetch-all", async () => {
// BAD: Untracked promises - may not complete before step returns
const users = fetchUsers();
const orders = fetchOrders();
return { users, orders }; // Returns promises, not values
});

How to Fix: Either use step.parallel for tracked concurrent operations, or properly await all promises inside a step.

Tell the AI:

For concurrent operations, prefer step.parallel so each operation is
independently memoized and tracked. If using Promise.all inside a step,
await the result - don't return unresolved promises.

The Problem: AI hardcodes URLs and configuration instead of using environment variables.

const client = createClient({
serverUrl: "http://localhost:9123", // BAD: Hardcoded
});

How to Fix: Use environment variables for URLs, API keys, and configuration.

Tell the AI:

Use environment variables for configuration:
- IRONFLOW_SERVER_URL for server address
- IRONFLOW_SIGNING_KEY for webhook verification
- Never hardcode production URLs

The Problem: AI uses any types instead of proper TypeScript types.

const processOrder = ironflow.createFunction(
{ id: "process-order", triggers: [{ event: "order.placed" }] },
async ({ event, step }) => {
const data = event.data; // any type - no autocomplete, no safety
await step.run("process", async () => {
return data.orderId; // Could crash at runtime
});
},
);

How to Fix: Define Zod schemas for event data and pass them via the schema config option.

Tell the AI:

Define Zod schemas for:
- Event data payloads
- Step return types
- Function return types
Pass the schema via createFunction({ schema: MySchema }) to get full type safety.

The Problem: AI puts side effects (database queries, external API calls, fetch) inside managed projections. Managed projections must be pure reducers.

const statsProjection = createProjection({
name: "stats",
events: ["order.completed"],
initialState: () => ({ total: 0 }),
handler: async (state, event) => {
// BAD: Side effect in a managed projection
await sendSlackNotification("New order!");
return { total: state.total + event.data.amount };
}
});

How to Fix: Use mode: "external" for projections that need to perform side effects. Managed projections must only return the new state based purely on the previous state and the event. Managed handlers must also be deterministic and must not change the state argument.

Tell the AI:

Managed projections (which return state) MUST be pure synchronous functions with NO side effects.
If you need to make API calls, send emails, or write to external databases, use an external projection (mode: "external") or a function.

12. Missing Expected Versions in Entity Streams

Section titled “12. Missing Expected Versions in Entity Streams”

The Problem: AI appends to entity streams without using optimistic concurrency control, which can lead to race conditions when multiple workers update the same entity.

// BAD: Unconditional append can overwrite other updates
await client.streams.append("user-123", {
name: "user.updated",
data: { status: "active" },
entityType: "user"
});

How to Fix: Always provide expectedVersion when appending to an entity stream if the append depends on previous state.

Tell the AI:

When appending events to entity streams, always use optimistic concurrency control by fetching the current version and passing it as expectedVersion in the options object.

The Problem: AI writes upcasters that fail to return all required properties, inadvertently deleting data during schema migrations.

registry.register("user.created", 1, 2, (data) => {
// BAD: Forgot to spread the rest of the old data!
// This deletes email, name, etc.
return {
fullName: `${data.firstName} ${data.lastName}`
};
});

How to Fix: Always spread the existing data payload (...data) or use destructuring to preserve un-migrated properties.

Tell the AI:

When writing event upcasters, always return ALL properties of the event data, not just the migrated fields. Use the spread operator to ensure data isn't lost during the upcast.

The Problem: AI reads the wall clock, a random ID, or an environment variable inside a managed projection. A rebuild reads the events table. The live NATS tail can call the reducer for the same event. Each call gets a different value, so the rebuilt state silently disagrees with the live state. No runtime check catches this.

// BAD: wall clock
handler: (state, event) => ({ ...state, lastSeen: Date.now() })
// BAD: random ID
handler: (state, event) => ({ ...state, lineId: crypto.randomUUID(), qty: event.data.qty })
// BAD: env read
handler: (state, event) => ({ ...state, region: process.env.REGION })

How to Fix: Derive timestamps from event.timestamp and IDs from event.data. In Go, do not call time.Now(), rand.Int*, uuid.New(), os.Getenv, or read files inside a managed handler. Use event.Timestamp and event.Data. See the four-rule reducer contract.

Tell the AI:

Managed projection handlers must be deterministic. Never read the clock,
random numbers, environment variables, or files inside them. Take every
value from the event.

The Problem: AI changes the state argument in place and returns the same object. This is an aliasing hazard. If the runner keeps the earlier reference (for progress reports, caching, or diagnostics), your change silently rewrites it. The Go SDK deep-copies state before each call, which removes the sharpest case. The JS SDK does not.

// BAD: mutates the argument
handler: (state, event) => { state.count += 1; return state; }
// BAD
Handler: func(state map[string]any, event ironflow.ProjectionEvent, _ ironflow.ProjectionContext) (map[string]any, error) {
state["count"] = state["count"].(float64) + 1
return state, nil
}

How to Fix: Always return a new state object from a managed handler.

Tell the AI:

Managed projection handlers must return a new state object. Never change
the state argument and return it.

The Problem: AI re-fetches a projection on a timer, or on a raw event subscription. The projection runner is asynchronous, so the fetch can return stale data.

// BAD
setInterval(() => ironflow.getProjection("name"), 2000);
ironflow.subscribe("events:order.>", { onEvent: () => ironflow.getProjection("name") });

How to Fix: Use subscribeToProjection. Fetch the initial state once with getProjection.

Tell the AI:

Never poll getProjection and never re-fetch it on a raw event subscription.
Use subscribeToProjection.

17. Querying Entity Streams for Display Data

Section titled “17. Querying Entity Streams for Display Data”

The Problem: AI loads entity stream history to render a list or a UI. The entity stream is the write model. Reading it for display couples the UI to the aggregate shape, and the cost grows with every entity’s history.

// BAD: the UI lists by folding entity streams
const orders = await Promise.all(ids.map(id => client.streams.read(id)));
return orders.map(foldOrder);

How to Fix: Entity streams enforce invariants on append. Projections serve reads. Do not cross them.

Tell the AI:

Do not read entity streams to build lists or views. Create a projection
for the view and read the projection.

The Problem: AI calls streams.append and then emit for the same fact. One append already writes two outbox rows in the same transaction (internal/eventtrigger/helper.go). One row goes to the entity topic for stream subscribers. The other goes to BuildUserEventTopic(eventName), which is the exact subject a projection’s durable filters on (internal/projection/coordinator.go). A second emit publishes the fact twice. The reducer runs twice and every total double-counts.

await client.streams.append(streamId, { name: "issue.created", data, entityType: "issue" },
{ expectedVersion: v });
await client.emit("issue.created", data); // BAD: the projection sees it twice

How to Fix: Use emit only for facts that have no entity stream. Pick one path for each fact, never both.

If last_event_seq is 0 while the stream holds events, the cause is a failed outbox drain, not a missing emit. The events-namespace row never published. Check the outbox, not the append site:

Terminal window
ironflow outbox dlq list --env <env> --json
ironflow outbox dlq requeue <event-id> --env <env>

Then check that the projection’s events array lists the event name. An unlisted name is ignored and looks the same from outside.

Tell the AI:

streams.append already delivers the event to projections. Do not also
call emit for the same fact.

The Problem: AI omits recording: true from the function config. Without it, ironflow inspect --at and the timeline view refuse to run. The plain step list still works.

{ id: "fn", triggers: [{ event: "order.placed" }] } // BAD: no time-travel debugging

How to Fix: Add recording: true to every function you may need to debug.

Tell the AI:

Set recording: true in the createFunction config.

The Problem: AI namespaces entity IDs with /. Slashes collide with REST path segments and dashboard links. The server rejects them at write time. Legacy streams created before this check show in the list view but return 404 on click. The same holds for ?, #, %, &, and whitespace.

// BAD: breaks every path-based route
await client.streams.append(`${venueId}/issue-${issueId}`, event, opts);

How to Fix: Use only letters, digits, -, _, ., :, and ~ in entity IDs.

Tell the AI:

Entity IDs may contain only letters, digits, "-", "_", ".", ":" and "~".
Never use "/" to namespace them.

The Problem: AI names events UserUpdated, OrderChanged, or ItemModified. These names erase intent. Consumers cannot tell why something changed, so they must inspect the diff. Over time every reactor grows a defensive if (event.data.something_changed) branch.

// BAD
await client.emit("user.updated", { email: "new@ex.com", reason: "user_request" });

How to Fix: If the event name does not tell you why, it is a CRUD event. Rename it to the domain action.

Tell the AI:

Name events for the domain action (user.email_changed), not for a CRUD
operation (user.updated). One fact per event.

The Problem: AI puts the full entity state in the event (...entity). This freezes the read shape into the stream. A schema change then needs upcaster work for every event instead of only the changed field.

// BAD: the event is a state snapshot
{ name: "order.placed", data: { ...entireOrderObject } }

How to Fix: Events carry what happened, not what the entity now looks like. Projections derive the current shape.

Tell the AI:

Events carry what happened, not the full entity state. Let projections
build the current shape.

The Problem: AI writes the entity stream as a dumb event log. The business rules then live in function bodies, projection handlers, or reactive listeners. This is CRUD with extra steps. The aggregate owns its invariants or it is not an aggregate.

// BAD: the rule runs in a downstream reactor, too late
const reactToPlaced = createFunction({ triggers: [{ event: "OrderPlaced" }] }, async ({ event }) => {
if (event.data.total > creditLimit) { /* now what? the event is already a fact */ }
});

How to Fix: Run all invariant checks in the pure decider, before streams.append. After the append, the event is a fact. See Invariants live in the decider.

Tell the AI:

Put business rules in a pure decider that runs before streams.append.
Do not enforce them in reactors or projections.

24. Present-Tense or Imperative Event Names

Section titled “24. Present-Tense or Imperative Event Names”

The Problem: AI names events in the imperative (PlaceOrder) or the present tense (OrderPlacing). Imperative names read as commands. Present-tense names read as process state. An event is a fact that already happened.

// BAD
const OrderEvents = { PLACE: "order.place", PROCESSING: "order.processing" } as const;

How to Fix: Use past tense {noun}.{verb-ed} for events. Use imperative {verb}.{noun} for commands. Do not add a commands. prefix. Ironflow does no routing on it, and the verb-first shape is the signal. See Commands vs events.

Tell the AI:

Events use past tense (order.placed). Commands use verb-first imperative
(place.order) with no "commands." prefix.

The Problem: AI writes event names as string literals in triggers. A typo compiles and the function never fires.

triggers: [{ event: "order.placed" }] // BAD: typos compile

How to Fix: Define event names once as constants (as const) and import them.

Tell the AI:

Define event names as shared constants and import them. Do not repeat
string literals.

The Problem: AI passes a typed managed projection to a TypeScript worker without the cast. A typed managed projection is not assignable to IronflowProjection<unknown, unknown>, so tsc fails.

createWorker({ projections: [myProjection] }); // BAD: type error

How to Fix: Add the cast in TypeScript workers. Go’s NewWorker and Python’s Worker(functions=[...], projections=[...]).run() take other configuration shapes. Python’s projections take ironflow.projection.create_projection(...) values, so Python needs no cast.

Tell the AI:

In TypeScript workers, cast each projection with "as IronflowProjection".

The Problem: AI calls bucket.get() and assumes the key exists. It throws when the key is missing.

const entry = await bucket.get(key); // BAD: throws if the key is missing

How to Fix: Wrap bucket.get() in try/catch and use a default value.

Tell the AI:

KV get throws when the key is missing. Catch the error and use a default.

The Problem: AI calls config.watch() or bucket.watch() and never stops the watcher. Each call leaks a server-side watch connection.

config.watch("flags", { onUpdate: handleFlags }); // BAD: never stopped

How to Fix: Keep the watcher and call stop() when the owner shuts down.

Tell the AI:

Store the return value of every watch() call and call stop() on it at
cleanup.

29. Webhook Without Signature Verification

Section titled “29. Webhook Without Signature Verification”

The Problem: AI writes a webhook with no verify step. Anyone can send fake payloads.

const stripe = createWebhook({
id: "stripe",
// BAD: no verify
transform: (payload) => ({ name: `stripe.${payload.type}`, data: payload.data.object }),
});

How to Fix: Always verify the provider’s signature in verify.

Tell the AI:

Every webhook must verify the provider signature. Never accept unsigned
payloads.

The Problem: AI reads secrets from process.env at module level. The value is fixed at import time, not at execution.

const key = process.env.STRIPE_KEY; // BAD: resolved at import

How to Fix: Declare the secret in the function config and call secrets.get() inside the handler. secrets hangs off the function context, not step. It is synchronous and throws when the secret is unset. It does not return undefined.

Tell the AI:

Declare secrets in the function config and read them with secrets.get()
inside the handler. Never read them at module level.

Some tasks should be done manually:

Don’t let AI generate:

  • Signing keys or secrets
  • Authentication logic
  • Permission checks
  • Encryption/decryption code

Instead, use established libraries and review security code manually.

Don’t let AI:

  • Configure production infrastructure
  • Set up monitoring/alerting rules
  • Create database migrations for production
  • Manage secrets in CI/CD

Review AI-generated code carefully for:

  • Financial calculations
  • Legal compliance logic
  • Data privacy handling
  • Multi-tenant isolation

After AI generates Ironflow code, verify:

  • Every step has a unique, descriptive ID
  • All external calls are inside step.run
  • External API calls use idempotency keys
  • NonRetryableError used for permanent failures
  • waitForEvent timeout strategy is intentional (hard fail via system.run.*.failed, or modeled as a fallback event the wait can match)
  • Correct execution mode (push vs pull)
  • Event match field names a field that exists in both events
  • Environment variables used for configuration
  • Types are properly defined (no any)
  • Managed projection handlers are pure, deterministic, and return a new state object
  • No streams.append plus emit for the same fact
  • Entity IDs contain only letters, digits, -, _, ., :, ~
  • Event names are past tense, and trigger names come from shared constants
  • Webhooks verify signatures, and secrets are read inside the handler
  • No sensitive data logged or exposed