Outbox
The outbox pattern records side-effect intent in the same database transaction as the business write. A separate worker drains those records after commit and delivers events or jobs with retries.
Use an outbox when an event or job must not be lost after the database commit: notifications, integrations, billing syncs, audit streams, search indexing, or other workflow-critical side effects.
bun add @beignet/coreWhy it exists
After-commit publishing avoids one class of bug: listeners, jobs, and mail do not see data that later rolls back. It still leaves a different failure window:
- The database transaction commits.
- The process starts publishing the event or dispatching the job.
- The process crashes or the provider call fails.
- The business record is durable, but the side effect is lost.
The outbox closes that gap by writing the event or job to an outbox_messages
table inside the transaction. Delivery becomes a retryable background workflow.
Outbox delivery is at least once, not exactly once. If the worker delivers a message and crashes before marking it delivered, the message may be delivered again. Use Idempotency inside listeners or job handlers when duplicate delivery would be harmful.
Use Workflow primitives to decide whether the side effect should be modeled as an event, job, notification, idempotent command, schedule, or outbox record, and see side effects after commit for the rule the outbox makes durable. Payment workflows often pair verified webhooks with outbox-backed entitlement, notification, or integration side effects; see Payments and billing.
Core API
Use @beignet/core/outbox for typed messages, registries, memory test storage,
and the drain worker:
import {
createMemoryOutbox,
defineOutboxRegistry,
drainOutbox,
} from "@beignet/core/outbox";The app-facing delivery port is intentionally small:
import type { OutboxAdminPort, OutboxPort } from "@beignet/core/outbox";
export type AppPorts = {
outbox: OutboxPort;
outboxAdmin: OutboxAdminPort;
};Production adapters implement:
| Operation | Purpose |
|---|---|
enqueue(...) | Store a pending event or job |
claimBatch(...) | Atomically claim available messages and reconcile eligible rows whose attempt budget is exhausted |
renewClaim(...) | Extend an active, unexpired claim with the current claim token |
markDelivered(...) | Ack a claimed message with its claim token |
markFailed(...) | Retry or dead-letter a claimed message |
Renew, ack, and fail operations require both the current claimToken and an
unexpired lease. This prevents an old worker from changing a message after its
claim expires. claimBatch(...) returns claimed messages separately from rows
it moved directly to deadLettered because a crashed worker had already used
the final attempt.
Keep OutboxAdminPort on operational contexts rather than transaction-scoped
use-case ports. It supports:
| Operation | Purpose |
|---|---|
listMessages(...) | Inspect pending, claimed, delivered, or dead-lettered rows |
countMessages(...) | Count rows before cleanup or alerting |
getMessage(...) | Inspect one payload, attempts, and last error |
requeueMessage(...) | Return a dead-lettered row to pending |
purgeDeadLettered(...) | Delete terminal dead-letter rows after review |
pruneDelivered(...) | Delete delivered rows older than a retention cutoff |
Transaction-scoped recording
Keep the existing Beignet event API in use cases. The outbox should sit behind
tx.events, not introduce a parallel use-case workflow:
const publishPostUseCase = useCase
.command("posts.publish")
.input(PublishPostInput)
.output(PostOutput)
.emits([PostPublished])
.run(async ({ ctx, input, events }) =>
ctx.ports.uow.transaction(async (tx) => {
const post = await tx.posts.publish(input.slug);
await events.record(tx.events, PostPublished, {
postId: post.id,
slug: post.slug,
publishedAt: post.publishedAt,
});
return post;
}),
);Wire tx.events with createOutboxEventRecorder(...) in production:
import {
createOutboxEventRecorder,
createOutboxJobDispatcher,
} from "@beignet/core/outbox";
import {
createDrizzleSqliteAuditLogPort,
createDrizzleSqliteOutboxPort,
createDrizzleSqliteUnitOfWork,
} from "@beignet/provider-db-drizzle/sqlite";
uow: createDrizzleSqliteUnitOfWork({
db: ports.db.drizzle,
createTransactionPorts: (tx) => {
const outbox = createDrizzleSqliteOutboxPort(tx);
return {
posts: createPostRepository(tx),
audit: createDrizzleSqliteAuditLogPort(tx),
events: createOutboxEventRecorder(outbox, {
tracing: ports.tracing,
}),
jobs: createOutboxJobDispatcher(outbox, {
tracing: ports.tracing,
}),
outbox,
};
},
});This keeps .emits(...) and events.record(...) enforcement intact while
moving durability into infrastructure. The returned recorder exposes only
record(...): it writes immediately through the transaction-scoped outbox and
does not pretend to be an inspectable buffer. Use
createDomainEventRecorder() when tests or non-durable Unit of Work adapters
need explicit entries(), clear(), and flush() behavior.
Passing the installed tracing port captures the current request or workflow span in the outbox row. Existing rows without trace metadata remain valid.
Transactional job enqueueing
Use createOutboxJobDispatcher(...) when a use case should enqueue a job
inside the same transaction as the business write. Wire it as the
transaction-scoped jobs port the same way createOutboxEventRecorder(...)
backs tx.events above, then use the normal job dispatcher shape:
await ctx.ports.uow.transaction(async (tx) => {
const appointment = await tx.appointments.create(input);
await tx.jobs.dispatch(SendAppointmentReminderJob, {
appointmentId: appointment.id,
});
return appointment;
});Use this for direct transactional job enqueueing. For event-driven workflows, prefer recording an event and letting a listener enqueue the job during outbox drain.
Drizzle-backed outboxes store the carrier in nullable trace_context_json.
Existing applications must add that column in their next migration before
deploying the updated adapter:
-- SQLite and Postgres
ALTER TABLE outbox_messages ADD COLUMN trace_context_json text;
-- MySQL
ALTER TABLE outbox_messages ADD COLUMN trace_context_json longtext;Registry and draining
The worker needs an explicit registry of typed events and jobs it may deliver:
bun beignet make outboxbeignet make event and beignet make job also create the outbox registry
and bounded drain route on first use. The generated server/outbox.ts has this
shape:
import { defineOutboxRegistry } from "@beignet/core/outbox";
import { createServiceActor } from "@beignet/core/ports";
import type { AppContext } from "@/app-context";
import { postEvents } from "@/features/posts/domain/events";
import { postJobs } from "@/features/posts/jobs";
import { getServer } from "@/server";
export const outboxRegistry = defineOutboxRegistry({
events: postEvents,
jobs: postJobs,
});
export async function createOutboxDrainContext(): Promise<AppContext> {
const server = await getServer();
return server.createServiceContext({
actor: createServiceActor("beignet-outbox"),
});
}
export async function stopOutboxDrainContext(): Promise<void> {
const server = await getServer();
await server.stop();
}server.createServiceContext(...) builds a
service context for the drain worker.
After server/outbox.ts exists, beignet make event and beignet make job
append new feature registries to it, and beignet doctor warns about feature
events and jobs the registry cannot deliver; beignet doctor --fix registers
them.
Registration and delivery wiring are separate. Events in the registry require
ctx.ports.eventBus; jobs require ctx.ports.jobs. beignet make job
declares and defers jobs and installs an app-owned inline dispatcher when no
dispatcher is already wired, while preserving an existing direct binding or
provider such as Inngest. A later Inngest or BullMQ preset replaces only that
marked generated fallback and rejects unmarked custom inline wiring.
beignet doctor reports
BEIGNET_OUTBOX_JOB_DISPATCHER_MISSING when the registry contains jobs but the
configured ports and port-wiring files do not declare and bind or defer that
port. Keep the jobs key explicit in definePorts(...); when custom spreads
or helper calls prevent doctor from verifying required port wiring, it reports
the uncertainty, and beignet make job stops instead of replacing a custom
dispatcher it cannot verify.
Drain messages from a cron route, worker process, queue consumer, or scheduled
task. In Next.js apps, prefer createOutboxDrainRoute(...) so the outbox runs
as a bounded serverless invocation instead of a provider startup loop:
// 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,
});The route verifies CRON_SECRET before resolving the server or assembling app
context, then runs authenticated requests through the normal raw-route
pipeline.
Push-assisted polling in Next.js
On Next.js 15.1 or newer, use after() to request one bounded drain after a
successful Unit of Work transaction. This removes the one-minute polling floor
for newly committed messages without running an idle poller. Keep the
Next-specific wrapper in server/providers.ts, after the database provider
that installs uow:
import { createObservedUnitOfWork } from "@beignet/core/ports";
import { createProvider } from "@beignet/core/providers";
import { createNextOutboxDrainTrigger } from "@beignet/next";
import { after } from "next/server";
import type { AppContext } from "@/app-context";
import type { AppPorts } from "@/ports";
import type { AppServiceContextInput } from "./context";
const outboxDrainProvider = createProvider<
Pick<AppPorts, "uow">,
AppContext,
AppServiceContextInput
>()({
name: "outbox-drain-trigger",
setup({ ports, createServiceContext }): { ports: Pick<AppPorts, "uow"> } {
const trigger: () => void = createNextOutboxDrainTrigger({
defer: after,
createContext: () => createServiceContext(undefined),
registry: async () => (await import("./outbox")).outboxRegistry,
});
return {
ports: {
uow: createObservedUnitOfWork({
unitOfWork: ports.uow,
afterCommit: trigger,
}),
},
};
},
});Register outboxDrainProvider after the database provider. This keeps
Next-specific server composition out of infra/db/provider.ts; the lazy
registry import avoids the cycle between server/providers.ts and
server/outbox.ts. Pass the service-context input your app requires; the
generated starter accepts undefined, while tenant-aware apps may provide an
explicit service actor or tenant.
This model is push-assisted polling, not durable execution. after() may
be missed during a crash or deployment, performs only one batch, and does not
wait for a message's future availableAt. Keep createOutboxDrainRoute(...)
and schedule it as a recovery sweep. About every 15 minutes is a useful
default when immediate first delivery comes from after(); shorten it when
the app's retry-latency requirement demands it.
Repeated triggers coalesce while a deferred drain is scheduled or running. This prevents transactions started by an outbox handler from recursively scheduling more drain callbacks; the recovery sweep handles messages left beyond the bounded batch.
Delayed messages and scheduled retries still depend on the recovery cron or a durable queue scheduler. Use a durable jobs provider when retries require a guaranteed low-latency wake-up. Apps on older Next.js versions remain valid with cron-only draining.
For non-Next runtimes, call drainOutbox(...) from the host's bounded
background entrypoint. Do not start setInterval polling from provider
lifecycle hooks in serverless apps.
For a local, CI, or worker-hosted drain, use the same server/outbox.ts module
with the CLI:
bun beignet outbox drain --batch-size 100 --concurrency 4The CLI loads outboxRegistry, creates the app context through
createOutboxDrainContext(...), drains one batch, records instrumentation,
then calls stopOutboxDrainContext(...) when present.
The default batch size is 100 and the default concurrency is 1. Increase
--concurrency only when handlers are safe to run in parallel; a value above
one does not preserve delivery order. The CLI exposes throughput controls but
uses the core lease, heartbeat, and maximum-active defaults. When those limits
need tuning, use an app-owned worker that calls drainOutbox(...) or configure
createOutboxDrainRoute(...) or createNextOutboxDrainTrigger(...).
See Runtime recipes for the difference between cron routes, worker-hosted drains, and command-based drains.
drainOutbox(...) first validates that every transport required by the
registry is available, then claims messages, validates payloads, publishes
events through eventBus, dispatches jobs through jobs, and marks each
message delivered. Missing transport wiring therefore fails before a claim and
does not consume delivery attempts. Failed deliveries are retried with backoff
until maxAttempts, then dead-lettered.
Each active delivery has a renewable lease. By default, Beignet claims for 30
seconds, renews every 10 seconds, and stops renewing a single delivery after 5
minutes. drainOutbox(...) claims only enough work to fill its active
concurrency slots, so queued work does not sit behind a serial batch while its
lease expires. Set leaseMs, heartbeatMs, and maxActiveMs together when a
provider or handler needs different limits. heartbeatMs must be shorter than
leaseMs. Reaching maxActiveMs stops further renewals; the last confirmed
lease can remain active until its own lockedUntil timestamp.
Outbox adapters compare application-supplied timestamps when claiming and
renewing leases. Synchronize every drain host with a reliable clock source.
Clock skew can make another worker treat a live claim as expired; leave enough
lease margin for expected scheduling delay and host-clock drift.
Event outbox rows store parsed, canonical Standard Schema output. Before writing
the row, Beignet verifies that the output is JSON-safe and unchanged when its
decoded JSON is validated again. An invalid event rejects with
EventTransportError instead of creating a row that would fail or change during
delivery. The drain repeats the check for existing rows before publishing. Job
rows preserve the original JSON-safe payload until the worker's handler-facing
parse.
Delivery, lease, and settlement failures are isolated per claimed message. The
drain retries a transient settlement write within the confirmed lease. If a
successful external delivery cannot be acknowledged, Beignet never calls
markFailed(...): doing so could label a delivered side effect as failed. The
result instead increments settlementFailed. A claim that cannot be renewed or
confirmed increments leaseLost. In either case, the durable final state is
unknown and another worker may later deliver the message again.
The drain result contains claimed, delivered, retried, deadLettered,
abandonedDeadLettered, settlementFailed, and leaseLost counters.
abandonedDeadLettered is included in deadLettered. A Next drain route
returns HTTP 500 when either uncertainty counter is nonzero, the CLI prints the
complete report and exits nonzero, and the MCP outbox_run tool returns the
same JSON report with an error result. Treat those outcomes as an operational
incident rather than retrying the command blindly. A settlement that reaches
its lease deadline can increment both uncertainty counters for the same
message.
When you pass ctx.ports.devtools or another instrumentation port to
drainOutbox(...), Beignet records first-class outbox events for delivered,
retried, and dead-lettered messages, including attempt counts, retry timing,
and a redacted error summary. createOutboxDrainRoute(...) passes the drain
request's requestId and traceId into those rows so the devtools request
view can expand into the messages delivered by that cron invocation.
When the drain context includes ports.errorReporter, the Next route and CLI
runner report dead-lettered messages, lease failures, and settlement failures.
Drain-level claim/infrastructure failures are also reported.
Scheduled retries remain instrumentation events and do not create incidents.
Direct drainOutbox(...) callers can use onDeadLetter, onLeaseError, and
onSettlementError to apply the same ownership policy. Lease callbacks
classify the current state as recovered, degraded, or lost; only lost
increments the uncertainty counter.
Outbox delivery uses the same retry vocabulary as
jobs. A retried message is marked pending again with a future availableAt
computed from the backoff, maxAttempts caps total delivery attempts, and a
dead-lettered message is in the terminal outbox state and is no longer retried
automatically.
For job messages, enqueueJob(...) and createOutboxJobDispatcher(...) use
the job definition's retry policy by default, and the
drain owns execution retries: when delivery goes through the inline
dispatcher, the drain detects it and runs the handler exactly once per pass,
so the job's policy is applied by outbox rescheduling rather than stacked
in-process retries. A configured inline-dispatcher onError observer still
runs, but it cannot swallow that single-attempt failure: the drain marks the
message failed and retries or dead-letters it. Durable providers are unaffected
— for them dispatch is an enqueue and the queue owns execution. Customize
the retry delay for outbox delivery when the worker needs an override:
await drainOutbox({
outbox,
registry,
eventBus,
jobs,
instrumentation: ports,
retryDelayMs: ({ message }) => Math.min(60_000, 1000 * message.attempts),
});Dead-letter recovery and cleanup
server/outbox.ts can use the same service context for drains and admin
commands when that context exposes ports.outboxAdmin. The Drizzle providers
export createDrizzleSqliteOutboxAdminPort(...),
createDrizzlePostgresOutboxAdminPort(...), and
createDrizzleMysqlOutboxAdminPort(...) for this root maintenance port.
Inspect dead-lettered rows:
bun beignet outbox list --status deadLettered
bun beignet outbox show <message-id>Requeue a reviewed dead-lettered message:
bun beignet outbox requeue <message-id> --reset-attemptsClean up terminal rows only after review:
bun beignet outbox purge --before 2026-01-01T00:00:00.000Z --dry-run
bun beignet outbox purge --before 2026-01-01T00:00:00.000ZPrune delivered rows by retention cutoff:
bun beignet outbox prune --before 2026-01-01T00:00:00.000Z --dry-run
bun beignet outbox prune --before 2026-01-01T00:00:00.000Zpurge targets only deadLettered rows using updatedAt as the cutoff.
prune targets only delivered rows using deliveredAt as the cutoff. Both
commands support --limit for bounded maintenance passes, delete the oldest
eligible rows first, and support --json for runbooks or dashboards.
Drizzle SQLite
@beignet/provider-db-drizzle includes a durable outbox adapter on its
/sqlite subpath:
bun add @beignet/provider-db-drizzleimport {
createDrizzleSqliteOutboxAdminPort,
createDrizzleSqliteOutboxPort,
createDrizzleSqliteOutboxSetupStatements,
} from "@beignet/provider-db-drizzle/sqlite";
const outbox = createDrizzleSqliteOutboxPort(db);
const outboxAdmin = createDrizzleSqliteOutboxAdminPort(db);Beignet does not hide migrations. Add the setup statements to your app-owned migration/bootstrap flow:
for (const statement of createDrizzleSqliteOutboxSetupStatements()) {
await client.execute(statement);
}The default table is outbox_messages; pass
{ tableName: "app_outbox_messages" } to both the setup statements and the
port to override it.
Testing
Use the memory adapter in use-case tests:
import {
createMemoryOutbox,
createOutboxEventRecorder,
} from "@beignet/core/outbox";
const outbox = createMemoryOutbox();
const uow = createNoopUnitOfWork(() => ({
posts,
events: createOutboxEventRecorder(outbox),
outbox,
}));Then assert pending messages or drain them:
expect(outbox.messages).toMatchObject([
{
kind: "event",
name: "post.published",
status: "pending",
},
]);Use direct in-memory event recorders and inline jobs when durability is not the behavior under test.
When not to use it
Do not force every side effect through the outbox.
Use direct after-commit event publishing or inline jobs for low-stakes local workflows, tests, and single-process development. Use the outbox when losing the side effect would create user-visible, financial, compliance, or workflow correctness issues.