Crux
GuidesBackground Work

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

EnvironmentRetention bindingRecommended setupInline workStrict boundary
Long-lived Node processnode()Optional inside Crux primitivesProcess-localwithNodeDefer for Node HTTP response finish
Next.js 15.1+next()config({ host: next() })Retained through after()withCrux or withNextDefer
Vercel Functionsvercel()config({ host: vercel() })Retained through waitUntil()withServerlessDefer when outcome grouping is required
Cloudflare Workersworkers({ ctx })Per request; use withCrux by defaultRetained through ctx.waitUntil()@use-crux/cloudflare withCrux
Lambda-class named-only hostCustom binding with supportsInline: falseNamed Runtime work onlyRejected explicitlywithNamedOnlyDefer
Convex bridgeInstalled by createCruxConvex().run()No separate bindingRejected explicitlyBridge run is the boundary
Other serverless hostCustom CruxHostBindingPlatform package or withServerlessDeferBinding-definedwithServerlessDefer

Next.js

Configure ambient retention once:

crux.config.ts
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

crux.config.ts
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.

On this page