Crux
GuidesDurable Execution

Application Work

Durably accept an exported Flow in one request and reconnect it in another.

Application Work lets a request accept a finite exported Flow without running it inline. Acceptance writes the Work record, initial Flow snapshot, normalized JSON input, exact generated definition, result obligation, and wake outbox row atomically. spawn() resolves after that commit and before execution.

Generate immutable target metadata

Export the Flow, then generate Runtime artifacts before building the application:

crux runtime generate

The generated .crux/generated/runtime/program.ts contains static target imports and each target's Project Index definition identity and fingerprint. Do not maintain a second target registry in application code.

Generated files do not exist before setup or generation. Import runtimeProgram from an application or request entry that is built after that step; do not import it from crux.config.ts while setup may still be creating the artifacts.

Bind the application host

Bind a Runtime and stable namespace. For local development, node() without a store uses process-local memory; production Work that must survive a process restart needs a durable Runtime store such as PostgreSQL:

import { createWorkHost, spawn } from "@use-crux/core";
import { node } from "@use-crux/core/runtime";
import { postgres } from "@use-crux/postgres/runtime";
import { runtimeProgram } from "./.crux/generated/runtime/program";
import { reviewDocument } from "./src/flows/review-document";

const workHost = createWorkHost({
  runtime: node({ store: postgres(), namespace: "app" }),
  program: runtimeProgram,
});

export async function POST(request: Request) {
  const input = await request.json();
  const work = await workHost.run(() =>
    spawn(reviewDocument, input, {
      idempotencyKey: request.headers.get("Idempotency-Key")!,
    }),
  );
  return Response.json({ workId: work.id }, { status: 202 });
}

createWorkHost() does not send a worker request or execute a Flow. It binds the immutable Runtime program to spawn() and getWork() for the active request scope.

Idempotency and reconnection

The idempotency tuple is Runtime namespace, exported target name, and caller key. A compatible retry returns the same Work. The same tuple with different normalized JSON input rejects with WORK_IDEMPOTENCY_CONFLICT; the same key for another target is independent.

Reconnect in a later request with the original exported Flow:

import { getWork } from "@use-crux/core";

const work = await workHost.run(() => getWork(reviewDocument, workId));
const status = await work.status();
const result = await work.result();

Passing another target rejects with WORK_TARGET_MISMATCH. A Work ID and an idempotency key are identifiers, not authorization; authenticate and authorize each application request before calling either API.

result() may be called immediately after acceptance. It waits while the generated Runtime worker executes the pinned Flow definition, then returns the Flow's exact inferred output. The terminal Work state and its immutable result reference commit together. Replayed wakes observe that terminal state without running another logical occurrence or replacing the result.

A Runtime-supported Flow barrier may move the same Work through suspended before it resumes and completes. Terminal failures expose only the safe public Work failure summary; thrown provider and user details are not retained there.

Durable result retention

The PostgreSQL and Convex Runtime stores retain the typed terminal result as canonical, content-addressed JSON. A restarted host reconnects with getWork() and reads the exact same value after an independent worker completes the pinned Flow. Duplicate wake delivery neither executes the Flow again nor replaces its result.

Referenced result payloads are retained during provider cleanup. If an operator removes a payload or its retention policy expires it, result() throws WorkResultExpiredError; it never re-enqueues or re-executes the Work. Store large application content separately and return a bounded reference from the Flow result.

Report progress

progress() replaces the latest safe progress snapshot. It does not enqueue a wake, resume suspended Work, or retain a history of every update:

await work.progress({
  message: "Reviewing sections",
  current: 3,
  total: 8,
});

Messages are limited to 1,024 characters. Counts must be finite, non-negative, and current cannot exceed total. A later update replaces the whole snapshot, so progress({ current: 4 }) removes the earlier message and total. Completed, failed, and cancelled Work rejects updates with WorkNotActiveError.

Cancel or detach

Cancellation is cooperative and idempotent:

const receipt = await work.cancel({ reason: "Request withdrawn" });

The optional safe reason is limited to 512 characters. The Runtime uses its existing cancellation transaction to terminalize the Work and cancel its owned Flow snapshot, waiter, and timer registrations. It does not promise physical preemption, and it does not roll back completed Effects; recovery stays an explicit rollback(work.effects) or Runtime worker policy. If completion wins the transaction race, the receipt is already-terminal with the completed status and the result remains intact. Repeating cancellation returns the current terminal state.

Use detach() when the owner no longer intends to join the Work but execution should continue:

const receipt = await work.detach();

Detachment changes only durable ownership. It is idempotent, does not cancel or re-enqueue execution, does not reparent the Work Effect scope, and does not prevent a later getWork() call from inspecting, joining, or recovering the same occurrence.

Stream safe lifecycle events

Without a cursor, stream() emits one replacement snapshot and then ordered status and progress events for that Work only:

let cursor: string | undefined;

for await (const event of work.stream({ after: cursor })) {
  cursor = event.cursor;
  if (event.type === "work.progress") {
    console.log(event.progress);
  } else {
    console.log(event.status.state);
  }
}

Event IDs and cursors are opaque and can be stored for reconnection. A retained after cursor resumes strictly after that event without another snapshot. If retention removed the cursor, the stream emits a fresh snapshot as the new baseline, then continues. Consumers may deduplicate by event.id.

Streams use the Runtime durable event port. They never include the Work result, raw thrown failures, inputs, or provider payloads. They end after the completed, failed, or cancelled status event; a replacement snapshot that is already terminal ends immediately.

Read bounded statistics

stats() returns the existing owner-scoped ScopeStats projection for the Work occurrence:

const stats = await work.stats();
console.log(stats.timing.activeTimeMs);
console.log(stats.lifecycle.suspensions);

The Runtime mechanically records active and suspended time, completion, and Work lifecycle facts such as suspension, resumption, and cancellation. The bounded ledger export is stored with the canonical Work record, so memory, PostgreSQL, and Convex hosts reconstruct the same projection after restart. It contains aggregate facts, not progress messages, results, or failures.

Runtime and adapter boundary

Application Work uses the Runtime's single Work record, state machine, cancellation composite, durable event port, and statistics ledger. Memory, PostgreSQL, and Convex persist the same lifecycle and safe application metadata; there is no separate Work queue or control log.

Use flow.run() when the request should await foreground completion. Use defer() for resultless follow-up work. Replay-safe child Work created inside a Flow remains a separate lifecycle concern from top-level application acceptance.

On this page