SaaS Starter
How-to

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-rollover ships as a recurring tick (hourly @ :05 UTC) installed by schedulePeriodRollover() at server startup. The payload is empty — the processor reads tick time from job.processedOn because 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.

On this page