Can you create infinite workflows with hooks and for await?

HEAD
8e87b24fix(@workflow/ai): support provider-executed tools (AI SDK v6) (#734)
This post might have stale content, as HEAD is 81 commits ahead.

you can create infinite workflows, using hooks + for await ..., right?

is that pattern recommended? it's pretty cool, the only thing i worry about is related to error handling and retries: if a step fails, it can't be skipped, right?

Avatar of anthropic/claude-opus-4.5
Claude Opus 4.5
commented

Now I have a comprehensive understanding to answer your question.


##Answer

Yes! You can absolutely create "infinite" workflows using hooks + for await...of. It's a documented and supported pattern:

export async function infiniteWorkflow() {
  "use workflow";

  const hook = createHook<{ message: string }>();

  for await (const payload of hook) {
    // This loop runs indefinitely, receiving events one by one
    await processMessage(payload.message);
    
    if (payload.message === "stop") {
      break; // Only way to exit
    }
  }
}

The hook implements AsyncIterable<T>, and the async iterator is literally:

// packages/core/src/workflow/hook.ts:177-181
async *[Symbol.asyncIterator]() {
  while (true) {
    yield await this;
  }
}

###Regarding error handling and retries:

You're right to be concerned! Here's what happens:

  1. Steps inside the loop have retries - By default, steps retry 3 times. You can customize with stepFn.maxRetries = N.

  2. If a step fails after all retries are exhausted, it throws a FatalError which bubbles up and fails the entire workflow (see packages/core/src/step.ts:112-137).

  3. You CANNOT skip a failed step - When step_failed is recorded, the step consumer rejects with a FatalError, and the workflow fails.

###Strategies for handling this:

1. Use try/catch within the loop:

for await (const payload of hook) {
  try {
    await processMessage(payload.message);
  } catch (e) {
    // Log and continue to next message
    console.error("Failed to process:", e);
  }
}

2. Use FatalError for known unrecoverable errors: This still fails the workflow, but skips retries.

3. Implement your own retry/skip logic in the step:

async function processMessage(msg: string) {
  "use step";
  
  try {
    return await doWork(msg);
  } catch (e) {
    // Return a failure marker instead of throwing
    return { success: false, error: String(e) };
  }
}

4. Use the rollback pattern (from the docs) if you need compensating actions when failures occur.

###Bottom line:

The pattern is cool and supported, but you need to handle errors at the application level if you want the loop to continue after a failure. The workflow engine itself doesn't have a "skip failed step" mechanism - a failed step (after retries) will always fail the workflow.

Avatar of julianbenegas
Julian Benegas
commented

what about this?

export async function infiniteWorkflow() {
  "use workflow";

  const hook = createHook<{ message: string }>();

  for await (const payload of hook) {
    // This loop runs indefinitely, receiving events one by one
    await processMessage(payload.message).catch(e => {
 if (e instanceof FatalError) {
   return
 } else {
   throw e
 }
});
    
    if (payload.message === "stop") {
      break; // Only way to exit
    }
  }
}
Avatar of anthropic/claude-opus-4.5
Claude Opus 4.5
commented

Great question! This pattern won't work as you might expect.

The issue is that when a step fails (after retries are exhausted), the FatalError is thrown during replay when the workflow re-executes and encounters the step_failed event in the event log.

Let me show you the relevant code:

// packages/core/src/step.ts:112-137
if (event.eventType === 'step_failed') {
  // Terminal state - we can remove the invocationQueue item
  ctx.invocationsQueue.delete(event.correlationId);
  // Step failed - bubble up to workflow
  setTimeout(() => {
    // ...
    const error = new FatalError(errorMessage);
    if (errorStack) {
      error.stack = errorStack;
    }
    reject(error);  // <-- This rejects the step's promise
  }, 0);
  return EventConsumerResult.Finished;
}

So your .catch() will catch the FatalError - that part works! The workflow will continue to the next iteration.

BUT here's the problem: the step_failed event is permanently recorded in the event log. Every time the workflow replays (which happens on every resume), it will:

  1. Hit the same step_failed event
  2. Throw the same FatalError
  3. Your .catch() catches it and returns
  4. Continue to the next iteration

So actually... this might work! Let me reconsider.

The workflow will:

  1. Process hook_received event #1
  2. See step_failed for that step → catch it, continue
  3. Process hook_received event #2
  4. Run the step (or replay its result)
  5. etc.

The key insight is that each iteration of the loop creates a new step with a new correlationId. The failed step is a distinct entity from subsequent steps.

One caveat: Make sure your catch logic is correct. You're currently returning undefined when catching FatalError, which means processMessage() resolves to undefined instead of failing. If your workflow logic depends on the return value, you'll need to handle that.

for await (const payload of hook) {
  const result = await processMessage(payload.message).catch(e => {
    if (FatalError.is(e)) {  // Use the static method for proper instanceof checks
      console.log("Step failed permanently, skipping:", e.message);
      return null;  // Explicit "failed" marker
    }
    throw e;
  });
  
  if (result === null) {
    continue;  // Skip to next message
  }
  
  // Use result...
}

So yes, this pattern should work for building resilient infinite workflows! 👍


END OF POST

Can you create infinite workflows with hooks and for await? — vercel/workflow