Handling Wait Steps Inside While Loops in Unmeshed
Learn how to configure the Wait step correctly when it’s nested inside a While loop, prevent accidental instant completions, and keep your long-running workflows predictable and robust.
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 + 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
};
}
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 thatwaitUntil
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 re‑uses 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
};
}
Why this works:
- Idempotent & 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 (e.g., store waitUntil in processContext ) | Useful when multiple steps need to reference a shared schedule. |
Dynamic While condition (move the timer logic into the While’s condition itself) | Handy for simple polling loops where you can express “stop when 3 seconds have passed” directly. |
Separate Wait step outside While | If you truly need only 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 info (e.g., the current iteration index) in the step output. It makes diagnosing timing issues much easier.
- ❌ Wait inside a loop does not behaves like
sleep()
in synchronous code; remember that the orchestration engine re‑evaluates the same node instance each pass.
Happy orchestrating! 🛠️