DEV Community

Cover image for Why Wait Steps Behave Differently Inside While Loops
The Unmeshed Team
The Unmeshed Team

Posted on • Originally published at unmeshed.io

Why Wait Steps Behave Differently Inside While Loops

Using Wait Steps in Unmeshed: Handling Delays Inside While Loops

Waiting—whether for a webhook, a human approval, or simply a fixed pause—shows up in almost every orchestration scenario. In Unmeshed, you model these pauses with the Wait step, which accepts a JavaScript function that returns a Unix epoch timestamp (waitUntil) indicating when the step should complete.

Most of the time that's all you need. But as soon as you nest a Wait step inside a While loop, an easy-to-miss nuance can trip you up and make the wait look "broken." This post explains that behavior and shows a clean pattern for making loops and waits play nicely together.

Recap: How a Wait Step Works

When the Wait step starts, Unmeshed passes the step's execution context to your JavaScript function. A common implementation adds a fixed offset to the step's start time:

(steps, context) => {
  const startDate = new Date(steps.__self.start); // epoch millis when the step first began

  return {
    waitUntil: startDate.getTime() + 3_000, // wait 3 seconds
  };
};
Enter fullscreen mode Exit fullscreen mode

steps.__self.start never changes for that step instance, so the first time through everything is perfect: the engine sleeps for three seconds and continues.

The Gotcha: Wait Inside While

A While step re-executes its inner graph as long as its condition remains true. Each inner step (including Wait) keeps its original start timestamp across iterations. That means:

  • Iteration 0 – Wait schedules start + 3,000 ms and pauses. ✅
  • Iteration 1 – The same Wait step runs again, but start is still the original value. Because that timestamp is already in the past, Unmeshed sees that waitUntil is satisfied and continues immediately. ⚠️

From the outside it looks like the engine ignored your wait—but it's really doing exactly what you asked.

A Robust Pattern: Per-Iteration Timestamps

The fix is to track a separate target time for every loop iteration. The snippet below pushes a new timestamp into an iterations array the first time each iteration executes, then reuses it on retries, restarts, or subsequent checks.

(steps, context) => {
  // Existing array (or []) pulled from the step's previous output
  const iterations = steps.__self.output.result?.iterations || [];
  const currentIter = steps.loop.output.iteration;

  // If we haven't stored a target time for this iteration yet, compute & save one
  if (iterations.length <= currentIter) {
    iterations.push(Date.now() + 3_000); // wait 3 seconds from *now*
  }

  return {
    waitUntil: iterations[currentIter],
    iterations, // persist the array for next pass
    loop: currentIter, // optional: surface the index for debugging
  };
};
Enter fullscreen mode Exit fullscreen mode

Why This Works

  • Idempotent and restart-safe. If the engine restarts mid-wait, the stored timestamp is still there.
  • True per-iteration delay. Each loop cycle gets a fresh waitUntil computed relative to when that cycle began—not when the Wait step was first created.

Alternative Approaches

Approach When to Use
Context-based key (for example, storing waitUntil in processContext) Useful when multiple steps need to reference a shared schedule.
Dynamic While condition (moving the timer logic into the While condition itself) Handy for simple polling loops where you can express "stop when 3 seconds have passed" directly.
Separate Wait step outside While Best when you only need one delay before the loop begins.

Best Practices Checklist

  • ✅ Treat Wait steps like any other stateful computation—make them idempotent and restart-safe.
  • ✅ When looping, compute targets relative to now, not to the original start time.
  • ✅ Always surface debugging information (such as the current iteration index) in the step output. It makes diagnosing timing issues much easier.
  • ❌ A Wait step inside a loop does not behave like sleep() in synchronous code. Remember that the orchestration engine re-evaluates the same node instance on every iteration.

Top comments (0)