Detecting Flow Completion
For developers whose application needs to know when a flow finished — to answer an HTTP request, send a notification, or trigger downstream work. It explains the three ways to learn a flow’s outcome and when to use each.
There are three ways to learn a flow’s outcome, and they suit different situations:
Await— block a live caller until the flow stops, then read the outcome.Poll—Awaitwith a bounded wait: on a ctx timeout it returns the flow’s current outcome instead of an error, so a caller whose own deadline is shorter than the flow can answer now and ask again.- Orchestration — model the follow-up work as tasks inside a workflow that calls the real work as a subgraph, so the reaction to success or failure is itself durable.
Pick Await when a caller is standing by and can wait for the result. Pick Poll when the caller is bounded
by its own deadline — an HTTP handler backing a status endpoint — but the flow is not. Pick orchestration when
the reaction must happen reliably regardless of who is (or isn’t) waiting — a push notification, a downstream
call, a compensation.
graph TD
classDef node fill:#32a7c1,color:#f4f2ef,stroke:#32a7c1
classDef ask fill:#e5f4f3,color:#434343,stroke:#32a7c1
linkStyle default stroke:#32a7c1
q1{"Must the follow-up happen<br/>even if nobody is waiting?"}:::ask -->|yes| O["Orchestration"]:::node
q1 -->|no| q2{"Is the caller's deadline<br/>shorter than the flow?"}:::ask
q2 -->|yes| P["Poll"]:::node
q2 -->|no| A["Await"]:::nodeAwait
outcome, err := eng.Await(ctx, flowKey)Await blocks until the flow stops (completed/failed/terminated/cancelled/interrupted) and returns the outcome.
It wakes promptly whichever replica ran the flow’s last step, with no configuration or host support required.
Good for: a request/response caller that holds the flowKey and can wait; tests; short flows.
Limits:
- The caller must stay alive and connected. If its context deadline fires before the flow stops,
Awaitreturns without the outcome — the flow keeps running, and the caller mustAwaitagain (it still holds the key). Long-running or human-in-the-loop flows routinely outlive a request context. - Give the ctx a deadline. It is the only bound on the wait: a flow runs for as long as its work takes, and there is nothing else for the engine to time out on. A ctx without a deadline is honored for a long fixed budget and then times out — a guard against blocking forever, not a wait to design around.
- It does not survive the caller process restarting — there is no durable “call me back.”
- It is a poor fit for fire-and-forget submissions, where nobody waits.
Poll
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
outcome, err := eng.Poll(ctx, flowKey)
if err != nil {
return err // a real failure: the flow is unknown, or the database is down
}
if !outcome.Stopped() {
// Still running. Answer the caller now; it will ask again.
return respond(http.StatusAccepted, outcome.Status)
}
return respond(http.StatusOK, outcome)Poll waits exactly like Await, but a ctx timeout is not an error: it returns the flow’s current outcome,
which has not stopped (Stopped() reports false). So it settles the “flow outlives the caller’s
deadline” problem in Await’s limits above without a hand-rolled Snapshot loop — and it still returns
promptly the moment the flow stops, rather than sleeping out a fixed interval.
Good for: an HTTP status endpoint, or any caller whose deadline is shorter than the flow. It gives you long-polling: wait up to your own budget, answer with whatever is true then, and re-poll.
Limits: the same as Await otherwise — the caller must hold the flowKey, and nothing durable happens
if it never comes back. For work that must happen regardless of who is waiting, use orchestration.
Orchestration (Recommended for Reliable Follow-Up)
Wrap the real work in an orchestrating graph: one task launches it as a subgraph, and separate tasks
handle the success and failure paths. Because each follow-up is its own step, it is checkpointed and can
be retried independently — a delivery that must not be lost belongs here, not in a caller’s Await.
orch := workflow.NewGraph("ProcessOrder")
orch.SetURL("CallSubgraph", "orch/call")
orch.SetURL("SubgraphSuccess", "orch/success")
orch.SetURL("SubgraphFailed", "orch/failed")
orch.AddTransition("CallSubgraph", "SubgraphSuccess") // normal path
orch.AddTransitionOnError("CallSubgraph", "SubgraphFailed") // error path
orch.AddTransition("SubgraphSuccess", workflow.End)
orch.AddTransition("SubgraphFailed", workflow.End)graph LR
classDef task fill:#32a7c1,color:#f4f2ef,stroke:#32a7c1
classDef term fill:#e5f4f3,color:#434343,stroke:#32a7c1
linkStyle default stroke:#32a7c1
t0["CallSubgraph"]:::task
t1["SubgraphSuccess"]:::task
t2["SubgraphFailed"]:::task
t0 --> t1
t0 -->|"onError"| t2
_start(( )):::term --> t0
t1 --> _end(( )):::term
t2 --> _end(( )):::termThe three roles (the names don’t matter — the shape does):
CallSubgraph — calls the subgraph, yields while it runs, and returns any error so the graph routes it:
func callSubgraph(ctx context.Context, f *workflow.Flow) error {
var out map[string]any
yield, err := f.Subgraph("the-real.workflow", f.Snapshot(), &out)
if err != nil {
return err // routes to SubgraphFailed via AddTransitionOnError
}
if yield {
return nil // child launched; this step parks until it completes
}
f.Set("result", out) // stash the outcome for the follow-up task
return nil // normal transition -> SubgraphSuccess
}SubgraphSuccess — runs next on the normal transition. Do the follow-up work here, and because it is its
own step it can retry itself until it succeeds. For example, if it makes a web call to notify an upstream
party, it retries that call as many times as needed within a bounded horizon:
func subgraphSuccess(ctx context.Context, f *workflow.Flow) error {
if err := notifyUpstream(ctx, f.Snapshot()); err != nil {
// Deliver-or-retry: keep trying with exponential backoff, up to an hour of total backoff.
if f.Retry(1*time.Second, 2.0, 1*time.Minute, 1*time.Hour) {
return nil
}
return err // horizon exhausted
}
return nil
}SubgraphFailed — wired with AddTransitionOnError, it runs when the subgraph fails. It has the error in
state (onErr) and can notify, compensate, or clean up — again as a retryable step of its own.
Why the split into separate tasks matters: each follow-up is a durable, independently retryable step. A
crash between the subgraph completing and the notification being delivered is recovered like any other step;
the delivery isn’t tied to a caller staying connected. Doing the follow-up work inside CallSubgraph
instead would couple it to the subgraph call’s own attempt and lose that independent retry.
Good for: guaranteed delivery, push notifications, downstream calls, compensation/saga steps, fire-and-forget submissions.
Limits: more graph authoring, and you implement the delivery task yourself (the engine won’t call an external party for you).
A Note on Interrupts
The orchestrator observes the subgraph’s closed outcome — success (normal transition) or failure
(onError). It cannot react to the subgraph interrupting: an interrupt parks the whole chain, including the
orchestrator’s CallSubgraph step, so no task runs at that moment. To notify that a flow is waiting for
input, put a notify task before the interrupting step — the task that is about to flow.Interrupt
knows it is about to wait, so it (or a step just ahead of it) reports “pending” first.
See also: Driving Flows · Fan-Out & Subgraphs ·
Writing Tasks (control signals, flow.Retry).
Next: Scheduling.