Runtime recipes

Beignet keeps runtime work behind explicit entrypoints. The web process serves HTTP. Cron routes trigger one bounded unit of work. Workers consume queues or repeat bounded commands. Provider-backed function hosts run provider-owned invocations. Tasks run when an operator, CI job, or release pipeline asks for them.

That separation is intentional: providers install ports and manage clients, but they should not start unbounded polling loops, queue consumers, or drain intervals during server boot. Put repeated work in the runtime that owns its scaling, shutdown, health checks, and credentials.

Runtime matrix

Choose the smallest process layout that matches the work your app actually does:

If your app usesRun thisBeignet surface
Contracts, auth, UI, OpenAPI, devtools, uploads, storage routes, and webhooksOne web processcreateApiRoute(getServer), createNextServer(...), or createFetchServer(...)
Best-effort cross-process eventsWeb processes with the Redis event bus provider registeredctx.ports.eventBus, createRedisEventBusProvider()
Durable events or outbox-backed jobsPush-assisted drain plus a recovery cron, or a drain commandcreateNextOutboxDrainTrigger(...), createOutboxDrainRoute(...), beignet outbox drain
BullMQ-backed jobsWeb process plus a worker processcreateBullMQJobWorker(...)
Inngest-backed jobsWeb process plus the Inngest route or function hostcreateInngestJobFunctions(...), serve(...)
SchedulesWeb process plus a protected cron route, scheduled function, or command schedulercreateScheduleRoute(...), beignet schedule run <name>
Backfills, repairs, imports, exports, and release maintenanceOne-off commandbeignet task run <name>

Use a single web process when all work is request/response. Add a cron route when work needs an HTTP-triggered scheduler. Add a worker process when work is long-running, repeated, or queue-backed. Add a provider function host only when the provider owns invocation and retry semantics.

Database clients by runtime

Every process that imports the app's module-scoped infra/db/client.ts gets one database client or pool. Warm requests in that process reuse it through the cached server loader; never create the client inside a route or context factory.

RuntimeDatabase posture
Long-lived Node/Bun serverUse one bounded pool and close it during graceful server shutdown.
Autoscaled containerMultiply the per-process pool maximum by the maximum replica count, including workers.
Serverless functionKeep the per-instance pool small and prefer the database host's pooled endpoint. Warm instances reuse the module singleton; cold instances create another pool.
Queue workerBudget a separate pool for every worker process. Worker concurrency above pool size queues database work rather than creating extra connections.
One-off task or migrationUse a short-lived client, close it before exit, and include concurrent release jobs in the connection budget.
Local SQLite fileRun one writable host. Process-local transaction serialization does not coordinate multiple machines.
Hosted libSQLReuse one remote client per process and follow the host's transaction and concurrency limits.

The web process and Better Auth should share the same client when they use the same database. Generated apps do this by injection and keep the shared client app/process-owned so retryable server initialization cannot close the client that auth still references. Their public server stop() closes it after a successful boot, which lets workers and one-off commands exit cleanly. See Database and transactions for the budget formula and provider API.

Web process

The web process answers traffic only. It owns API routes, auth callbacks, webhooks, OpenAPI, devtools when enabled, uploads, storage routes, and process-local health endpoints.

In Next.js, expose the central API from a catch-all route:

// app/api/[[...path]]/route.ts
import { createApiRoute } from "@beignet/next";
import { getServer } from "@/server";

export const { DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT } =
  createApiRoute(getServer);

For Bun, Deno, Cloudflare Workers, Node fetch servers, and other Web Fetch runtimes, use @beignet/web and serve the server.fetch handler from the host that owns the process.

Do not start queue consumers, outbox polling loops, schedule intervals, or long-running workers from the web process in serverless deployments. A container web process may make outbound provider calls while handling requests, but background repetition should still live in a worker, cron, or command runtime.

Liveness and readiness

Keep liveness and readiness separate:

Generated apps expose both endpoints. A readiness route can use createHealthRoute(...) from @beignet/next and provider-owned checkHealth() helpers:

// app/api/ready/route.ts
import { createHealthRoute } from "@beignet/next";
import { env } from "@/lib/env";
import { getServer } from "@/server";

export const { GET } = createHealthRoute(
  getServer,
  {
    checks: {
      database: (ports) => ports.db.checkHealth(),
      cache: (ports) => ports.redis.checkHealth(),
      search: (ports) => ports.meilisearch.checkHealth(),
      storage: (ports) => ports.s3Storage.checkHealth(),
    },
    timeoutMs: 2000,
  },
  env.NODE_ENV,
);

Only include checks for providers your app actually installs. Readiness probes should be cheap and non-mutating: select 1, Redis PING, queue metadata, search health, storage bucket access, or a provider health endpoint. Do not run migrations, outbox drains, schedule handlers, queue consumers, imports, or backfills from readiness routes.

First-party providers expose these useful readiness helpers:

Provider areaPort or escape hatchProbe
Drizzle databasectx.ports.dbcheckHealth()
Redis cachectx.ports.redischeckHealth()
Redis event busctx.ports.redisEventBuscheckHealth()
Redis locksctx.ports.redisLockscheckHealth()
BullMQ jobsctx.ports.bullMQJobscheckHealth({ timeoutMs })
Meilisearchctx.ports.meilisearchcheckHealth()
S3-compatible storagectx.ports.s3StoragecheckHealth()

For worker readiness, expose the same kind of bounded check from the worker host or run it before accepting work. A BullMQ worker, for example, should prove Redis and the app database are reachable before it starts claiming jobs.

Cron and schedules

Use cron routes when the deployment platform owns the schedule trigger. In Next.js apps, createScheduleRoute(...) keeps the route small: it authenticates with CRON_SECRET, runs the schedule through the server request pipeline, records instrumentation, and returns a status:

// app/api/cron/digests/daily-digest/route.ts
import { createScheduleRoute } from "@beignet/next";
import { env } from "@/lib/env";
import { getServer } from "@/server";
import { schedules } from "@/server/schedules";

export const runtime = "nodejs";

export const { GET, POST } = createScheduleRoute({
  server: getServer,
  schedules,
  schedule: "digests.send-daily",
  secret: env.CRON_SECRET,
  source: "vercel-cron",
});

Use beignet schedule run when the host can run a command instead of calling HTTP, such as a release job, container worker, CI job, or scheduler with command support:

beignet schedule run digests.send-daily --scheduled-at 2026-01-01T09:00:00.000Z

Schedules are trigger definitions. When missed or failed work needs durable retry and dead-letter behavior, keep the schedule handler small and enqueue a job or write an outbox message.

Outbox drains

Use createOutboxDrainRoute(...) for deployments where a platform cron invokes HTTP. The route should drain one bounded batch and exit:

// app/api/cron/outbox/drain/route.ts
import { createOutboxDrainRoute } from "@beignet/next";
import { env } from "@/lib/env";
import { getServer } from "@/server";
import { outboxRegistry } from "@/server/outbox";

export const runtime = "nodejs";

export const { GET, POST } = createOutboxDrainRoute({
  server: getServer,
  registry: outboxRegistry,
  secret: env.CRON_SECRET,
  batchSize: 100,
});

Use the CLI when the host can run a bounded command:

beignet outbox drain --batch-size 100

Both paths use the same server/outbox.ts registry. The HTTP route creates context through the server request pipeline; the CLI path uses createOutboxDrainContext(...) from server/outbox.ts to build an app service context. A long-running worker may repeat bounded drain passes, but the loop belongs to the worker host, not provider lifecycle hooks.

On Next.js 15.1 or newer, createNextOutboxDrainTrigger(...) can pass one bounded drain to after() after a successful transaction. In server/providers.ts, use createObservedUnitOfWork(...) to replace the base database Unit of Work and resolve server/outbox.ts lazily inside the deferred callback. Keep the cron route as a recovery sweep, typically around every 15 minutes, because after() is a latency optimization rather than a durable wake-up.

Each push-assisted trigger performs one batch. It does not wait for delayed messages or scheduled retries, and a process interruption can prevent the callback from running. Tight retry latency requires a durable jobs provider; older Next.js apps use the same cron-only route without the trigger.

Workers

Worker processes own long-running or repeated background work. They can run queue consumers, repeated outbox drains, or command schedulers, and they should still call Beignet primitives internally so app context, ports, logging, audit, and devtools instrumentation remain consistent.

For BullMQ, consume an explicit list of Beignet job definitions with createBullMQJobWorker(...):

// server/workers/jobs.ts
import { createBullMQJobWorker } from "@beignet/provider-jobs-bullmq";
import { SendWelcomeEmailJob } from "@/features/users/jobs";
import { getServer } from "@/server";

const server = await getServer();

export const jobsWorker = createBullMQJobWorker({
  queueName: process.env.BULLMQ_QUEUE_NAME ?? "beignet-jobs",
  redisUrl: process.env.BULLMQ_REDIS_URL ?? "redis://localhost:6379/0",
  prefix: process.env.BULLMQ_PREFIX ?? "beignet",
  jobs: [SendWelcomeEmailJob],
  ctx: () => server.createServiceContext(),
  workerOptions: {
    concurrency: 5,
  },
  errorReporter: ({ ctx }) => ctx.ports.errorReporter,
  infrastructureErrorReporter: server.ports.errorReporter,
});

const health = await jobsWorker.checkHealth({ timeoutMs: 5_000 });
if (!health.ok) {
  throw new Error(health.error.message);
}

Give workers the same provider environment they need to build app ports, plus worker-specific settings such as queue Redis URLs, concurrency, and shutdown timeouts. Handle process shutdown by closing workers so BullMQ stops claiming new work and gives active work a chance to finish. The app entrypoint should also enforce a deadline shorter than the host's termination grace period, then stop the server-owned database, telemetry, and other resources after the worker has drained.

URL-created producers fail quickly during Redis outages; URL-created workers use persistent consumer retry behavior. Explicit connection objects remain app-owned. Keep BullMQ Redis on maxmemory-policy=noeviction, configure persistence, bound completed and failed retention, and keep sensitive data out of job payloads.

Prefer the outbox when work must commit atomically with database writes. Prefer provider-backed jobs when the provider should own queueing, scheduling, invocation, and retry behavior.

Provider-backed functions

Inngest owns queueing, invocation, and retry behavior for direct Inngest-backed jobs. The Beignet provider installs ctx.ports.jobs for dispatch and exposes helpers that map Beignet job definitions to Inngest functions:

// server/inngest.ts
import { createServiceActor } from "@beignet/core/ports";
import { createInngestJobFunctions } from "@beignet/provider-jobs-inngest";
import { userJobs } from "@/features/users/jobs";
import { inngest } from "@/infra/inngest";
import { getServer } from "@/server";

export const inngestJobs = [...userJobs] as const;
export const inngestFunctions = createInngestJobFunctions({
  client: inngest,
  jobs: inngestJobs,
  ctx: async () => {
    const server = await getServer();
    return server.createServiceContext({
      actor: createServiceActor("beignet-inngest"),
    });
  },
  instrumentation: async () => (await getServer()).ports,
  errorReporter: async () => (await getServer()).ports.errorReporter,
});
// app/api/inngest/route.ts
import { serve } from "inngest/next";
import { inngest } from "@/infra/inngest";
import { inngestFunctions } from "@/server/inngest";

export const { GET, POST, PUT } = serve({
  client: inngest,
  functions: inngestFunctions,
});

Treat provider-backed functions as worker entrypoints, not web request handlers. They need the same service-context decisions as other background work: actor, tenant, app ports, logging, audit, and failure reporting. The generated lazy resolvers avoid server boot during route-module import. Use INNGEST_DEV=1 only for local development; production cloud execution needs both INNGEST_EVENT_KEY and INNGEST_SIGNING_KEY.

For Web Fetch and other non-Next hosts, the provider preset generates createAppInngestFunctions(...) instead of assuming getServer() exists. Create the app server through the host's normal lifecycle, supply that server's service-context factory and ports, and pass the resulting functions to Inngest's runtime adapter.

Best-effort events

The Redis event bus provider gives cross-process Pub/Sub delivery while subscribers are online. Every process that should receive an event must register its listeners in that process. If a web process publishes an event and no worker is subscribed, Redis does not replay the missed message later.

The env-backed provider opens one publisher and one subscriber connection per process and targets a standalone Redis URL or managed service with one Redis-compatible endpoint. ioredis reconnects and restores subscriptions after a transient disconnect, but events sent during the disconnected interval are not replayed. Include both connections in the process connection budget and host subscribers in long-lived web or worker processes, not ephemeral request-only runtimes. If the first subscription command fails after ioredis exhausts its command retries, Beignet retries the desired subscription with capped backoff until it succeeds or its final local handler unsubscribes.

Sentinel and Cluster topologies require app-owned ioredis constructor options. Create those clients in app infrastructure and adapt them with createRedisEventBus(...); validate subscription recovery against the exact host because Beignet's live suite currently proves only the single-endpoint topology. HTTP-only Redis APIs cannot host the persistent Pub/Sub subscriber. The provider health check pings both connections but does not prove that every expected listener registered its channel.

Use Redis Pub/Sub for best-effort notifications, cache invalidation, or in-process fan-out where missed messages are acceptable. Use the outbox or a job provider for workflow-critical side effects that need persistence, retry, acknowledgement, or dead-letter state.

One-off tasks

Use app tasks for backfills, repairs, imports, exports, and release maintenance:

beignet task run posts.backfill-search --input '{"dryRun":true}'

Tasks should call use cases and ports rather than reaching into infra directly. Prefer CLI entrypoints over exposed HTTP routes for work that operators or CI trigger by hand. Pass --tenant when the app's createTaskContext(...) resolves operational tenant scope separately from the task input payload.

Runtime checklist

Before shipping a runtime topology, confirm: