Queue a background job
Declare a queue, enqueue work, write a worker handler, monitor with Bull Board.
Background jobs use BullMQ on Redis. The queue catalog is a closed set declared in code so producers and consumers can't disagree on a name or payload shape.
The catalog
apps/server/src/infrastructure/jobs/queues.ts:
export const QUEUE_NAMES = {
emails: 'emails',
webhooksOutgoing: 'webhooks-outgoing',
billingWebhooksRetry: 'billing-webhooks-retry',
accountDeletions: 'account-deletions',
dataExports: 'data-exports',
usagePeriodRollover: 'usage-period-rollover',
} as const;
export interface JobDataMap {
emails: EmailJobData;
'webhooks-outgoing': WebhooksOutgoingJobData;
'billing-webhooks-retry': BillingWebhooksRetryJobData;
'account-deletions': AccountDeletionJobData;
'data-exports': DataExportJobData;
'usage-period-rollover': UsagePeriodRolloverJobData;
}[!NOTE]
usage-period-rolloverships as a recurring tick (hourly @ :05 UTC) installed byschedulePeriodRollover()at server startup. The payload is empty — the processor reads tick time fromjob.processedOnbecause BullMQ persists repeating-job payloads at schedule time. See Usage metering for the full lifecycle.
A misspelled queue name fails at compile time. A wrong payload shape fails at compile time.
Default retry policy
apps/server/src/infrastructure/jobs/queue-factory.ts sets the default per-job options:
export const DEFAULT_JOB_OPTIONS: JobsOptions = {
attempts: 3,
backoff: { type: 'exponential', delay: 5_000 },
removeOnComplete: { age: 60 * 60 * 24, count: 1_000 },
removeOnFail: { age: 60 * 60 * 24 * 7 },
};3 attempts with exponential backoff anchored at 5 s, completed jobs swept after 24 h, failed jobs kept for 7 days. Producers can override per-call.
1. Add a new queue
// apps/server/src/infrastructure/jobs/queues.ts
export const QUEUE_NAMES = {
/* ... */,
reportExports: 'report-exports',
} as const;
export interface ReportExportJobData {
readonly userId: string;
readonly reportId: string;
readonly format: 'csv' | 'pdf';
}
export interface JobDataMap {
/* ... */,
'report-exports': ReportExportJobData;
}The queue is auto-registered for Bull Board iteration.
2. Write the worker handler
apps/server/src/infrastructure/jobs/processors/report-exports.processor.ts:
import type { Processor } from 'bullmq';
import type { ReportExportJobData } from '../queues.js';
export const reportExportsProcessor =
(deps: { logger: Logger; reports: ReportsService }): Processor<ReportExportJobData> =>
async (job) => {
deps.logger.info({ jobId: job.id }, 'export starting');
await deps.reports.export(job.data);
};Wire it in bootstrap/worker.ts so the worker boots with that handler attached. The worker process and the API process share the queue but run independently — the API enqueues, the worker drains.
3. Enqueue from a use case or listener
await deps.jobs.enqueue('report-exports', {
userId: input.userId,
reportId: input.reportId,
format: 'pdf',
});jobs is the JobScheduler port (see infrastructure/jobs/bullmq-job-scheduler.adapter.ts). Enqueueing from a domain event listener is the most common pattern.
To override retry options for a specific call:
await deps.jobs.enqueue('report-exports', payload, { attempts: 5, delay: 30_000 });4. Idempotency
Workers retry. Handlers must be idempotent. Two patterns:
- Deterministic external operation: include a key the third party can dedupe on (Stripe
Idempotency-Key, an outbox row keyed on the job id, etc.). - Internal write: gate on a "processed at" column or a unique index that absorbs the second insert.
The boilerplate's billing webhook retry queue uses the provider event id as the idempotency key — replays no-op.
5. Monitor
Bull Board is mounted at /admin/queues when BULL_BOARD_ENABLED=true (default in dev, off in prod). It's guarded — you need an authenticated session with admin grants to reach it.
Counts and per-queue depth ship to Prometheus via the /metrics endpoint. Alert on bullmq_failed_jobs_total rising or bullmq_waiting_jobs staying non-zero longer than expected.
Testing
Unit-test the wrapper (the use case that calls jobs.enqueue) with a fake JobScheduler that records calls. End-to-end BullMQ tests need a real Redis — BullMQ uses Lua + cmsgpack that ioredis-mock cannot run. Test the handler logic in isolation; reserve the full end-to-end against Redis for the small set of jobs whose Redis interaction is the thing under test.