Host support
Configure retention once, and add a strict lifecycle wrapper only when the handler boundary needs it.
Application code always imports defer from @use-crux/core. A host binding
describes how the platform retains the root pending set; it never changes where
an inner primitive drain starts.
Memory uses the same binding. Its default capture.mode: "deferred" retains a
completed-turn capture when the active scope and host can keep it alive. If
retention is unavailable, memory falls back to awaited inline capture: the
write remains correct, but the operation may take longer and development
builds warn once. Use capture.mode: "inline" for intentional read-after-write
behavior. Memory does not accept a separate waitUntil hook.
Binding matrix
| Environment | Retention binding | Recommended setup | Inline work | Strict boundary |
|---|---|---|---|---|
| Long-lived Node process | node() | Optional inside Crux primitives | Process-local | withNodeDefer for Node HTTP response finish |
| Next.js 15.1+ | next() | config({ host: next() }) | Retained through after() | withCrux or withNextDefer |
| Vercel Functions | vercel() | config({ host: vercel() }) | Retained through waitUntil() | withServerlessDefer when outcome grouping is required |
| Cloudflare Workers | workers({ ctx }) | Per request; use withCrux by default | Retained through ctx.waitUntil() | @use-crux/cloudflare withCrux |
| Lambda-class named-only host | Custom binding with supportsInline: false | Named Runtime work only | Rejected explicitly | withNamedOnlyDefer |
| Convex bridge | Installed by createCruxConvex().run() | No separate binding | Rejected explicitly | Bridge run is the boundary |
| Other serverless host | Custom CruxHostBinding | Platform package or withServerlessDefer | Binding-defined | withServerlessDefer |
Next.js
Configure ambient retention once:
import { config } from "@use-crux/core";
import { next } from "@use-crux/next";
export default config({ host: next() });Inside Crux primitives, drains start immediately at their own boundary. A
root-level call gets an ephemeral invocation and runs inside Next after().
Add withCrux when the route needs one grouped invocation, handler outcome
classification, and the structured observability drain:
import { defer } from "@use-crux/core";
import { withCrux } from "@use-crux/next";
export const POST = withCrux(async () => {
defer(() => flushAnalytics());
return Response.json({ ok: true });
});Vercel Functions
import { config } from "@use-crux/core";
import { vercel } from "@use-crux/vercel";
export default config({ host: vercel() });Vercel calls waitUntil(work()). Deferred callbacks may therefore overlap a
streaming response body after the handler returns.
Cloudflare Workers
ExecutionContext belongs to one request. Prefer the package lifecycle
boundary, which receives the right ctx for every invocation:
import { defer } from "@use-crux/core";
import { withCrux } from "@use-crux/cloudflare";
export default {
fetch: withCrux(
async (_request, _env, _ctx) => {
defer(() => flushAnalytics());
return new Response("ok");
},
{ context: (_request, _env, ctx) => ctx },
),
};For a custom per-request boundary, construct workers({ ctx }) for that
request and pass it as the binding. Calling workers() without a reachable
context fails with DEFER_CAPABILITY_MISSING; Crux does not accept unretained
work.
Node HTTP
Node's process lifetime needs no retention binding for primitive-local work. Root-level request work needs the response boundary:
import { createServer } from "node:http";
import { defer } from "@use-crux/core";
import { withNodeDefer } from "@use-crux/core/defer/node";
createServer(
withNodeDefer(async (_request, response) => {
defer(() => flushAnalytics());
response.end("ok");
}),
).listen(3000);Multiprocess Node keeps inline work local to one worker. Use named Runtime work when execution must survive or cross workers.
Named-only hosts
Lambda-class hosts without a real post-return retention port must not accept
inline closures. withNamedOnlyDefer rejects the callback overload and keeps
the named Runtime path explicit:
export const handler = withNamedOnlyDefer(
async (event: { id: string }) => {
await defer(postProcess, { id: event.id });
return { ok: true };
},
{ host: "lambda", durableFinalization: true },
);Config-only trade-off
Core has no request identity without a wrapper. Each ambient root-level
defer() call therefore owns its own ephemeral invocation and retention task.
Limits apply per call; cross-call limits, handler outcome classification, and
the strict response commit barrier require a real wrapper or primitive scope.