Skip to content

Run a background job

Create a project now and process work after the request. This guide extends the Projects tutorial with a job that reads the saved project and logs its ID. You will record the job in SQLite alongside the project, deliver it through an outbox drain, and test retries and rollback.

The log gives you a visible result without another service. Replace the handler with your application work when you need to generate a report or call an integration. Jobs that charge money, send messages, or make other lasting changes also need idempotent handlers.

Before you start

Complete the Projects tutorial, including ownership and the UI, using the Next.js starter and SQLite. Keep its database, signed-in account, and app configuration. Run commands from that app's root with Bun. Other runtimes and job providers have their own setup in the Jobs reference.

This guide uses the existing outbox adapters and the generated inline job runner. The outbox saves pending work; the runner executes it when a drain picks it up. An inline dispatch by itself would run in the caller, so the create use case will enqueue into the transaction's outbox, rather than execute the handler.

You will edit these files after generating the job:

FileChange
features/projects/jobs/log-created.tsDefine the payload and work to perform
features/projects/use-cases/create-project.tsSave the project and job in one transaction
infra/db/provider.tsShare the existing transaction factory with the database test
features/projects/tests/background-job.test.tsCheck queued delivery, retry, validation, and rollback

Generate the job

bash
bun beignet make job projects.log-created
bun install
bun run format
bun beignet db generate
bun beignet db migrate
bun beignet db status

The generator adds lib/jobs.ts, the job and feature registry, the central server/outbox.ts registry, an outbox table, and the jobs provider. It also wires the transaction's outbox and creates app/api/cron/outbox/drain/route.ts. Review and keep the new migration. If CRON_SECRET is absent from .env.local, copy its generated entry from .env.example before starting the app.

Keep those generated registrations. Replace the generated job with this complete file:

typescript
// features/projects/jobs/log-created.ts
import { z } from "zod";
import { defineJob } from "@/lib/jobs";

export const LogCreatedJobPayloadSchema = z.object({ id: z.uuid() });
export type LogCreatedJobPayload = z.infer<typeof LogCreatedJobPayloadSchema>;

export const LogCreatedJob = defineJob("projects.log-created", {
	payload: LogCreatedJobPayloadSchema,
	async handle({ payload, ctx }) {
		const project = await ctx.ports.projects.findById(payload.id);
		if (!project) return;
		ctx.ports.logger.info("Project creation processed", {
			projectId: project.id,
		});
	},
});

Pass a project ID rather than a request context or a user object. The handler loads the current record through the repository port. If the project has been deleted before delivery, there is no work left to do.

Record the job with the project

Apply this change to the create use case:

features/projects/use-cases/create-project.ts

requireUserId still runs before the write. Both tx.projects and tx.outbox use the same database transaction. A failure rolls back the project and pending job together. The outbox allows up to three delivery attempts for this job.

Keep this distinction when adapting the example: enqueueJob(tx.outbox, ...) records work for later. ctx.ports.jobs.dispatch(...) uses the configured runner immediately; the starter's inline runner would execute the handler in the request.

Share the transaction setup with the test

Extract the existing transaction factory in infra/db/provider.ts. The app and the isolated database test will then use the same repository and outbox wiring:

infra/db/provider.ts

Every port returned by createTransactionPorts receives the same tx handle. Keep this factory in the database provider so future generators can extend it.

Test delivery and rollback

Add this complete test. It calls the real create use case against an isolated SQLite database, then drains the registered job. Its first delivery fails on purpose; advancing the test clock makes the retry eligible without waiting.

typescript
// features/projects/tests/background-job.test.ts
import { createUseCaseTester } from "@beignet/core/application";
import { createInlineJobDispatcher } from "@beignet/core/jobs";
import { drainOutbox, enqueueJob } from "@beignet/core/outbox";
import {
	createTestContextFactory,
	createTestPorts,
	createTestUserActor,
} from "@beignet/core/testing";
import {
	createDrizzleSqliteOutboxAdminPort,
	createDrizzleSqliteOutboxPort,
} from "@beignet/provider-db-drizzle/sqlite";
import type { AppContext } from "@/app-context";
import { LogCreatedJob } from "@/features/projects/jobs/log-created";
import { createProjectUseCase } from "@/features/projects/use-cases";
import { createAppUnitOfWork } from "@/infra/db/provider";
import { user } from "@/infra/db/schema";
import { createTestDatabase } from "@/infra/db/test-database";
import { initialPorts } from "@/infra/port-wiring";
import { expect, test } from "@/lib/beignet-test";
import { outboxRegistry } from "@/server/outbox";

test("project jobs wait for a drain, retry failures, and roll back with the write", async () => {
	const database = await createTestDatabase();
	const received: string[] = [];
	let failDelivery = true;
	try {
		const owner = {
			id: "job-owner",
			name: "Owner",
			email: "job-owner@example.com",
		};
		await database.db.insert(user).values({
			...owner,
			createdAt: new Date(),
			updatedAt: new Date(),
		});
		const uow = createAppUnitOfWork(database.db);
		const outbox = createDrizzleSqliteOutboxPort(database.db);
		const admin = createDrizzleSqliteOutboxAdminPort(database.db);
		const fixture = createTestPorts<AppContext["ports"]>({
			base: initialPorts,
			overrides: {
				projects: database.repositories.projects,
				uow,
				logger: {
					info(message) {
						if (failDelivery) throw new Error("Temporary delivery failure");
						received.push(message);
					},
				},
			},
		});
		const createContext = createTestContextFactory<
			AppContext,
			AppContext["ports"]
		>({
			ports: fixture.ports,
			actor: createTestUserActor(owner.id),
			auth: { user: owner, session: { id: "job-session" } },
		});
		const tester = createUseCaseTester<AppContext>(createContext);
		const project = await tester.run(createProjectUseCase, {
			name: "Background work",
		});
		expect(
			await database.repositories.projects.findById(project.id),
		).not.toBeNull();
		expect(await admin.countMessages({ status: "pending" })).toBe(1);
		expect(received).toEqual([]);

		const jobs = createInlineJobDispatcher<AppContext>({ ctx: createContext });
		let now = new Date();
		const drain = () =>
			drainOutbox({
				outbox,
				registry: outboxRegistry,
				jobs,
				retryDelayMs: 1000,
				now: () => now,
			});
		expect((await drain()).retried).toBe(1);
		failDelivery = false;
		now = new Date(now.getTime() + 1000);
		expect((await drain()).delivered).toBe(1);
		expect(received).toEqual(["Project creation processed"]);
		expect(await admin.countMessages({ status: "delivered" })).toBe(1);
		expect((await drain()).claimed).toBe(0);

		let rolledBackId = "";
		await expect(
			uow.transaction(async (tx) => {
				const unsaved = await tx.projects.create({
					name: "Roll back",
					userId: owner.id,
				});
				rolledBackId = unsaved.id;
				await enqueueJob(tx.outbox, LogCreatedJob, { id: unsaved.id });
				throw new Error("Cancel transaction");
			}),
		).rejects.toThrow("Cancel transaction");
		expect(
			await database.repositories.projects.findById(rolledBackId),
		).toBeNull();
		expect(await admin.countMessages()).toBe(1);
		await expect(
			enqueueJob(outbox, LogCreatedJob, { id: "not-a-uuid" }),
		).rejects.toThrow();
		expect(await admin.countMessages()).toBe(1);
	} finally {
		await database.close();
	}
});
bash
bun run format
bun run test features/projects/tests/background-job.test.ts
bun beignet check

Expect the test to pass: the job stays pending until drained, a failed attempt retries, successful delivery logs once in this test, a subsequent drain finds nothing, and a rollback saves neither the project nor its job. An invalid payload must also be rejected. Passing this test does not imply exactly-once delivery in production.

Try it in the app

Restart the development server after the generated environment and schema changes:

bash
bun run dev

Sign in at localhost:3000 and open Projects. Create a project named Background work. Expect it to appear in the list and remain there after a reload.

The generated Next.js provider requests a bounded drain after the response. Look in the server terminal for Project creation processed with the new project's ID. From a second terminal in the same app directory, inspect and drain:

bash
bun beignet outbox list --status pending
bun beignet outbox drain --batch-size 100
bun beignet outbox list --status delivered

Expect a delivered projects.log-created message. If automatic delivery already ran, the pending list can be empty and the CLI drain can report zero claimed messages. If the CLI performs the delivery, the log appears in that terminal. Creating a second project records another job; editing an existing project does not trigger this create-only workflow.

What you seeWhat to check
No outbox tableApply the generated migration to the same database used by the app.
Pending messages but no logRun the drain and inspect its result; a pending row means the work has not finished.
No pending messagesCheck the delivered list: the after-response drain may have finished already.
A retry or dead-letter resultRead the failure with bun beignet outbox show <message-id> and fix the handler or dependency. Follow recovery steps before requeueing.

Keep delivery running in production

An after-response trigger can be missed during a crash or deployment. Schedule the generated authenticated drain route as a recovery sweep, including for scheduled retries. See outbox drain deployment for host setup. Keep the application and drain pointed at the same database; a local SQLite file is not shared between separate hosts.

Delivery is at least once. A crash after the handler succeeds but before the outbox acknowledges it can cause another execution. Duplicate log lines are acceptable here. Use idempotency for effects that must not repeat, and recheck current access in handlers when background work acts on behalf of a user.

For long-running work or a separate queue, choose Inngest or BullMQ. With either provider, keep the outbox drain running to enqueue committed work, and run the provider's function endpoint or worker to execute it. An outbox delivery then confirms queue acceptance, not handler completion.

Work with your agent

Use this prompt from the completed Projects app:

text
Read https://beignetjs.com/markdown/agent-instructions.md and this app's local
instructions. Follow https://beignetjs.com/markdown/guides/background-jobs.md.

First verify that the Projects tutorial, ownership rules, and SQLite setup are
present. Preserve existing app customizations and follow installed package
guidance if its version differs from the website.

Generate projects.log-created, record the job in the same transaction as project
creation, and add the database test for delivery, retry, validation, and rollback.
Run the formatter and beignet check. Verify the new project over HTTP or in the
browser, and inspect its outbox delivery. Explain any failed or skipped checks,
the files changed, and how I can repeat the verification.

Continue with Jobs for payloads, dispatchers, and worker options, or return to Guides to choose the next task.