Background Work
Start follow-up work at the nearest Crux boundary and retain it for the life of the host invocation.
defer() starts with the execution boundary you already have. Inside an agent
turn, adapter call, tool execution, or Safety session, Crux opens that boundary
automatically:
import { defer } from "@use-crux/core";
const enrichCustomer = async (customer: Customer) => {
defer(() => updateSearchIndex(customer));
return customer;
};The callback is lazy. It starts when the nearest execution scope closes, not when an enclosing request eventually finishes. A tool can therefore begin its cleanup while the enclosing agent continues.
Choose the smallest integration that proves enough
Level 0: use a Crux primitive
On a long-lived process, nothing else is required inside a defer-capable Crux primitive. Registrations belong to the nearest scope and its limits, evidence, and outcome.
Level 1: configure host retention
Freezing platforms need a promise that keeps the invocation alive. Configure the platform binding once:
import { config } from "@use-crux/core";
import { next } from "@use-crux/next";
export default config({ host: next() });This does not delay primitive drains. They still start when their scope closes; the binding only retains the root until every drain under it settles.
With an ambient-capable binding, root-level defer() also works without a
route wrapper. Each root-level call owns a separate ephemeral invocation. That
mode deliberately has no cross-call grouping, handler outcome classification,
or strict response commit barrier.
Cloudflare Workers needs the request's ExecutionContext. Use the Workers
withCrux boundary or pass workers({ ctx }) to a per-request boundary; do not
put one request's ctx in process-global configuration.
Level 2: add a strict boundary when the handler matters
Use a wrapper when you need root-level calls grouped into one real invocation, failed-handler outcome classification, a strict named-work commit barrier, or framework observability cleanup:
import { defer } from "@use-crux/core";
import { withCrux } from "@use-crux/next";
export const POST = withCrux(async () => {
defer(() => flushAnalytics());
return Response.json({ ok: true });
});Wrappers are the strict layer, not the prerequisite for using defer() inside
Crux primitives.
Drain and retention are different guarantees
- Drain: work starts when its registering scope closes.
- Retention: the configured host keeps the root alive until all drains are empty, including work registered while another drain is running.
- Outcome: inline callbacks registered by a failed, cancelled, or timed-out scope are recorded as skipped and are not invoked.
- Acceptance: a retention-port failure propagates. Crux will not report success after accepting work the host cannot keep alive.
Callback failures after acceptance are contained and observed; they do not replace an already-produced handler result.
Best-effort and durable work
Inline callbacks are process-local. Use them for analytics, cache warming, cleanup, and other work that may be retried elsewhere or safely lost after a process crash.
Use named Runtime work when the work must survive restarts:
import { defer } from "@use-crux/core";
import { durableTask } from "@use-crux/core/runtime";
const sendReceipt = durableTask("send-receipt", {
run: async (input: { orderId: string }) => emailReceipt(input.orderId),
});
const ref = await defer(sendReceipt, { orderId: order.id });The promise resolves after durable acceptance. Design named targets to be idempotent because durable execution may retry them.
Replayable flows
Public defer() is not a replay escape hatch. Inside a replayable flow body,
use flow.defer() so the work keeps stable replay identity:
await flow.defer(sendReceipt, { orderId: order.id });