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 uses | Run this | Beignet surface |
|---|---|---|
| Contracts, auth, UI, OpenAPI, devtools, uploads, storage routes, and webhooks | One web process | createApiRoute(getServer), createNextServer(...), or createFetchServer(...) |
| Best-effort cross-process events | Web processes with the Redis event bus provider registered | ctx.ports.eventBus, createRedisEventBusProvider() |
| Durable events or outbox-backed jobs | Push-assisted drain plus a recovery cron, or a drain command | createNextOutboxDrainTrigger(...), createOutboxDrainRoute(...), beignet outbox drain |
| BullMQ-backed jobs | Web process plus a worker process | createBullMQJobWorker(...) |
| Inngest-backed jobs | Web process plus the Inngest route or function host | createInngestJobFunctions(...), serve(...) |
| Schedules | Web process plus a protected cron route, scheduled function, or command scheduler | createScheduleRoute(...), beignet schedule run <name> |
| Backfills, repairs, imports, exports, and release maintenance | One-off command | beignet 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.
| Runtime | Database posture |
|---|---|
| Long-lived Node/Bun server | Use one bounded pool and close it during graceful server shutdown. |
| Autoscaled container | Multiply the per-process pool maximum by the maximum replica count, including workers. |
| Serverless function | Keep 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 worker | Budget a separate pool for every worker process. Worker concurrency above pool size queues database work rather than creating extra connections. |
| One-off task or migration | Use a short-lived client, close it before exit, and include concurrent release jobs in the connection budget. |
| Local SQLite file | Run one writable host. Process-local transaction serialization does not coordinate multiple machines. |
| Hosted libSQL | Reuse 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:
/api/healthis cheap and local. It answers whether the process can return a response./api/readyruns bounded dependency checks. It answers whether the process should receive traffic.
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 area | Port or escape hatch | Probe |
|---|---|---|
| Drizzle database | ctx.ports.db | checkHealth() |
| Redis cache | ctx.ports.redis | checkHealth() |
| Redis event bus | ctx.ports.redisEventBus | checkHealth() |
| Redis locks | ctx.ports.redisLocks | checkHealth() |
| BullMQ jobs | ctx.ports.bullMQJobs | checkHealth({ timeoutMs }) |
| Meilisearch | ctx.ports.meilisearch | checkHealth() |
| S3-compatible storage | ctx.ports.s3Storage | checkHealth() |
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.000ZSchedules 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 100Both 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:
- The web process only serves HTTP and health routes.
- Every cron route or operational HTTP route is protected with a secret or app-owned authorization.
- Every scheduled or drain invocation does one bounded unit of work.
- Every worker has readiness checks for the dependencies it must reach before claiming work.
- Provider lifecycle hooks install ports, prepare clients, and close resources, but do not start unbounded loops.
- Best-effort event buses are not used for workflow-critical delivery.
- Durable side effects use outbox rows, jobs, idempotency, or provider-owned retry semantics.
Related pages
- Going to production for preflight checks, secrets, host settings, and production hardening.
- Schedules for schedule definitions and cron route behavior.
- Outbox for transactional delivery, retries, and dead-letter state.
- Jobs for BullMQ and Inngest provider behavior.
- Tasks for operational task definitions and CLI execution.
- Providers for lifecycle and setup order.