Durable vs transient workflows
Choose per workflow whether Flux persists every task-level checkpoint or only the outer lifecycle — and when at-most-once transient execution is the right trade.
Every Flux workflow declares a durability mode:
@workflow.with_options(durability="durable") # the default
async def billing_run(ctx: ExecutionContext[str]): ...
@workflow.with_options(durability="transient")
async def agent_hop(ctx: ExecutionContext[str]): ...
durable is the mode described everywhere else in these docs: every task-level state transition is persisted as an event, so a crashed execution replays from the last checkpoint and completed tasks never re-run. transient deliberately gives that up for workflows where replay is worthless and event volume is the dominant cost.
What durable buys you
With durability="durable" (the default, and what you get from a bare @workflow):
- Every
TASK_STARTED/TASK_COMPLETED/TASK_FAILED— plus retry, fallback, and rollback variants — is checkpointed to the server as it happens. - A worker crash or runner child crash releases the claim; the execution is re-dispatched and deterministic replay resumes from the last persisted task.
- Pause, human approvals, and schedules all work — they depend on replayable task history.
- The event log answers “what state is this execution in, and what did each step return?” after the fact.
If you are unsure, stay durable. See Why durable execution for the argument from first principles.
What transient changes
With durability="transient", only the outer lifecycle is persisted: the execution row, dispatch, and terminal state. The execution still appears in flux execution list with its final COMPLETED/FAILED state, but the worker suppresses every intermediate checkpoint and the terminal checkpoint carries only the WORKFLOW_* events.
The exact semantics and restrictions:
- No task-level checkpoints. Intermediate task events are never persisted. Measured on an 8-task workflow: 4.0 persisted event rows per execution versus 19.9 durable, with unchanged latency.
- At-most-once, no replay. A retried or requeued transient execution re-runs all tasks from scratch — there is no replay short-circuit. If a runner child crashes mid-run, the execution fails terminally with
WorkerProcessCrashedinstead of being re-dispatched; the caller decides whether to retry. - Pause and approvals are hard errors. Both need replayable task history, so
pause()andrequires_approvaltasks raiseTransientDurabilityErrorat runtime. - Schedules are rejected at decoration time. Combining
durability="transient"withschedule=...raisesValueErrorwhen the module loads. - Works in both dispatch modes and with sync, async, and streaming callers.
- Inside the workflow,
ctx.is_transientreports the mode.
When transient is right
Transient exists for workflows where the intermediate history has no value but its storage cost is real:
- High-frequency AI mesh hops. Agent-to-agent calls whose per-task payloads (LLM inputs and outputs) would otherwise dominate
execution_eventsgrowth. - Hot paths where replay is worthless. Short, cheap, idempotent-by-nature workflows that are faster to re-run than to replay — request/response-shaped work wearing a workflow interface.
Pair with runner="inprocess" to also skip the per-execution process spawn — the lowest-latency configuration for trusted mesh hops:
@workflow.with_options(durability="transient", runner="inprocess")
async def classify(ctx: ExecutionContext[str]): ...
The same-worker fast path
A mode="sync" call() whose target is a transient workflow object — the decorated object itself, not a string reference — with runner unset or "inprocess" executes in-process on the calling worker: no dispatch round-trip, no execution row, no checkpoints. Measured: ~2.3 ms median per hop versus ~526 ms for a server-relayed transient execution. This is the true agent-to-agent path.
from flux.tasks import call
@workflow.with_options(durability="transient")
async def summarize(ctx: ExecutionContext[str]): ...
@workflow
async def agent(ctx: ExecutionContext[str]):
# Object target + transient + sync => same-worker fast path
result = await call(summarize, ctx.input)
What to know about a fast-path hop:
- The parent’s task event is the per-hop audit record; aggregate visibility comes from the
flux_transient_hops_totalcounter andflux_transient_hop_duration_secondshistogram. - The hop runs inside the parent execution’s capacity slot — it does not consume a slot of its own.
- Secret access during the hop is audited under the parent execution.
- String references, durable targets, and
mode="async"calls always relay through the server, which owns service discovery and the durable lifecycle.
Disable the fast path fleet-wide with [flux.workers] transient_fast_path = false (or FLUX_WORKERS__TRANSIENT_FAST_PATH=false) to force every call() through the server.
Quick comparison
durable (default) | transient | |
|---|---|---|
| Task-level checkpoints | Every transition persisted | None |
| Crash recovery | Re-dispatch + replay from last checkpoint | Terminal failure; caller retries |
| Retry semantics | Completed tasks never re-run | All tasks re-run from scratch |
| Pause / approvals | Supported | TransientDurabilityError |
| Schedules | Supported | Rejected at decoration time |
Visible in flux execution list | Yes | Yes (outer lifecycle only) |
| Event rows (8-task workflow, measured) | ~19.9 | ~4.0 |
What’s next
- Why durable execution — what you are opting out of when you go transient.
- Execution runners —
inprocesspairing and crash semantics. workflowSDK reference — the fullwith_optionssignature.- Retention — the other lever on
execution_eventsgrowth.