Suspend and resume
Pause flows for human approval or external events, persist state, and resume execution, possibly in a different process.
Some pipelines can't finish in a single call. A content pipeline might need editorial approval before publishing. A budget request might need manager sign-off. An agent plan might need the user to confirm before execution begins.
flow.suspend() pauses the flow at a named point and returns control to the caller. The flow's state, which steps completed and what they returned, is persisted to your RecordStore. Later, an external signal triggers a resume, and execution continues from where it left off. Completed steps replay instantly from cache; no function is re-executed.
import { flow } from "@use-crux/core";
import { z } from "zod";
const reviewFlow = flow("content-review", async (flow) => {
const draft = await flow.step("draft", () =>
generate(writer, { model, input: { topic } }),
);
// Suspend here, no code after this line runs in this call
const approval = await flow.suspend<{ approved: boolean }>("editor-review", {
schema: z.object({ approved: z.boolean() }),
timeout: "24h",
});
if (!approval.approved) return flow.cancel("Rejected by editor");
return flow.step("publish", () =>
generate(publisher, { model, input: { draft: draft.object } }),
);
});
const result = await reviewFlow.run();
// result.status === 'suspended'
// result.flowId === 'flow-...', save this to signal laterSignaling a Suspended Flow
Signal a suspended flow via the handle's .signal() method. The signal name must match the suspend point name:
// Recommended, use the handle
await reviewFlow.signal(flowId, "editor-review", { approved: true });This works from API routes, webhook handlers, UI buttons, or any other external trigger.
Use handle.signal(). signalFlow() (exported from @use-crux/core) is the
low-level primitive it delegates to; see the flow
reference for
when to reach for it directly.
Resuming
Resume a suspended flow by passing its flowId to .resume(). The same handler runs again, but completed steps return their cached output without re-executing:
const final = await reviewFlow.resume(flowId);
// final.status === 'completed'
// final.output === publish resultDuring resume:
'draft'was already completed: returns cached output instantly'editor-review'signal was delivered: returns{ approved: true }immediately- Execution continues from the publish step
The handler body is identical for initial execution and resume. You don't write separate "suspend" and "resume" code paths, the same function handles both. Steps before the suspend point replay from cache; steps after it execute normally.
Observability
Suspension is a canonical lifecycle state, not just metadata. When a flow
suspends, Crux emits span:end with status: "suspended" for the active flow
and step spans, and the run read model shows the run as suspended until it
continues or finishes.
The flow snapshot also stores the parent observability context. When the flow
resumes in another worker, the resumed spans append to the same run id instead
of creating a second standalone run. In Convex, flow.signal(ctx, actionRef, flowId, name, payload) from @use-crux/convex/server writes the signal and
schedules the resume action with that stored context automatically.
Timeouts and Expiration
When a timeout is set, the flow expires if no signal arrives within the window. On resume, the executor detects the expiration and returns { status: 'expired' }:
const approval = await flow.suspend("budget-approval", {
timeout: "4h",
onExpired: ({ flowId, suspendedAt }) => {
console.log(`Flow ${flowId} expired, suspended since ${suspendedAt}`);
},
});Timeout strings support h (hours), m (minutes), s (seconds), and ms (milliseconds).
Cancellation
Cancel a flow from inside the flow function or externally:
// From inside, throws to unwind, returns { status: 'cancelled' }
if (!approval.approved) return flow.cancel("Rejected by editor");
// From outside, updates the stored snapshot
import { cancelFlow } from "@use-crux/core";
await cancelFlow(flowId, "Budget exceeded");Listing Suspended Flows
Query your store for flows by status:
import { listFlows } from "@use-crux/core";
const suspended = await listFlows({ status: "suspended" });
// [{ flowId, name, status, suspendedAt, createdAt, updatedAt, timeoutAt? }]waitUntil, Condition-Based Suspend
When you don't need a signal payload but want to wait for a condition to become true:
await flow.waitUntil(
"data-ready",
async () => {
const status = await checkExternalSystem();
return status === "complete";
},
{ timeout: "1h" },
);waitUntil re-suspends if the condition is still false on resume. Core does not poll, schedule, or wake the
flow in the background; the condition is checked again only when your app explicitly calls handle.resume(flowId).
Adapters such as Convex can schedule that resume action, but scheduling is adapter behavior, not a core guarantee.
How It Works
Suspend, flow.suspend() throws a FlowSuspendedError to unwind the call stack. The internal executor catches it, persists a snapshot of completed steps to the store, and returns { status: 'suspended' }.
Signal, External code calls handle.signal(flowId, name, payload) to deliver the signal. Internally, this writes the payload to the RecordStore as a key-value entry. In Convex, @use-crux/convex/server flow handles can also schedule the resume action.
Resume, handle.resume(flowId) loads the snapshot, replays completed steps from cache (no re-execution), and when suspend() is reached again, it finds the signal in the store and returns the payload instead of throwing.
The flow handler itself is stateless. All state lives in the store. This means a flow can suspend in one process (a Convex action, a Lambda, an API route) and resume in a completely different one, as long as both share the same RecordStore.
flow.suspend() works by throwing. Code after suspend() in the same block
does not execute during the initial call, only after resume. If you need
to run cleanup before suspending, do it before calling suspend().
Related
- Convex flows,
@use-crux/convex/serverflows for cross-action suspend/resume - Storage,
RecordStorebackends that persist flow snapshots - API reference, full type signatures for
suspend,signal,cancelFlow,listFlows