Recipe: Add a background job
Recipes
Add a BullMQ queue and worker for background work (e.g., sending webhooks on account upgrades). Jobs survive API restarts and show up in Bull Board during dev.
30 min
Estimated duration
BullMQ
Job Queue
Valkey
Broker
The API app’s QueueManager already owns the queue + worker lifecycle. This recipe shows how to add a new queue, end to end.
Prereqs
Section titled “Prereqs”- A working local stack.
- Read Queues once for the file shape.
There’s no new:queue scaffolder. Copy the file pattern from an existing queue instead. Use src/queues/email-delivery/ as the reference shape. Every queue directory carries six files prefixed with the queue name:
- webhook-fanout.constants.tsqueue name, job options
- webhook-fanout.types.tsjob payload type
- webhook-fanout.queue.tsproducer (enqueue API)
- webhook-fanout.worker.tsconsumer (handler)
- webhook-fanout.setup.tswire queue + worker into QueueManager
- index.tsre-exports
The @boring-stack-pkg/eslint-plugin-bullmq
plugin enforces this shape. Files outside the pattern fail the merge gate.
-
Create the directory and copy the file skeleton from
email-delivery/:Terminal window cd apps/api && mkdir -p src/queues/webhook-fanoutcp src/queues/email-delivery/email-delivery.*.ts \src/queues/webhook-fanout/# then rename the copied files from email-delivery.* to webhook-fanout.* -
Define the job payload type in
webhook-fanout.types.ts:export interface IWebhookFanoutJob {accountId: string;event: "account.upgraded" | "account.cancelled";targetUrl: string;payload: Record<string, unknown>;} -
Set the queue name and options in
webhook-fanout.constants.ts:export const WEBHOOK_FANOUT_QUEUE = "webhook-fanout" as const;export const WEBHOOK_FANOUT_DEFAULT_OPTS = {attempts: 5,backoff: { type: "exponential", delay: 1_000 },removeOnComplete: 1_000,removeOnFail: 5_000,} as const; -
Add the producer in
webhook-fanout.queue.ts:export async function enqueueWebhookFanout(queue: Queue<IWebhookFanoutJob>,job: IWebhookFanoutJob,) {return queue.add(job.event, job, WEBHOOK_FANOUT_DEFAULT_OPTS);} -
Write the worker in
webhook-fanout.worker.ts:export async function webhookFanoutWorker(job: Job<IWebhookFanoutJob>) {const res = await fetch(job.data.targetUrl, {method: "POST",headers: { "content-type": "application/json" },body: JSON.stringify(job.data.payload),});if (!res.ok) throw new Error(`webhook responded ${res.status}`);return { delivered: true };}Throwing makes BullMQ retry with the configured backoff. Make sure your handler is idempotent. The same job can fire multiple times.
-
Wire the queue and worker into
webhook-fanout.setup.ts, then register the setup function insrc/config/setup-queues.tsalongside the existing queues (email-delivery,notification-dispatch, etc.). -
Producer code calls it via the
QueueManager:await queueManager.enqueueWebhookFanout({accountId,event: "account.upgraded",targetUrl: "https://hooks.example.com/upgrades",payload: { plan: "pro" },});When
QUEUES_ENABLED=false(default in tests), the manager runs the worker inline so test paths don’t need a real Valkey.
Verify
Section titled “Verify”-
Boot the optional Bull Board overlay to watch jobs:
Boot Bull Board OverlayLaunches Valkey-backed BullMQ dashboard $ WITH_BULLMQ=1 ./scripts/compose-up.sh ok bull-board ready on http://bull-board.localhost -
Visit
http://bull-board.localhost(or the dev URL printed in the API logs) to see the queue, active jobs, and retry counts. -
Trigger the producer (test with
bun testor a curl to a route that enqueues). The job appears in Bull Board’s “completed” or “failed” tab. -
The audit log captures lifecycle events if you wrap the worker in the audit helper.
What changes in code
Section titled “What changes in code”apps/api/src/queues/webhook-fanout/: new directory, 6 files, lint-enforced shape (constants / types / queue / worker / setup / index).apps/api/src/config/setup-queues.ts: call your new setup function alongside the existing queues.apps/api/src/queues/queue-manager.ts: add theenqueueWebhookFanout(...)method that wraps the producer for callers.- Wherever your producer lives (a service file): call
queueManager.enqueueWebhookFanout(...).
No new dependencies. The pattern is reused for every kind of background work; the email pipeline, notifications, and audit log already follow it.
Related
Section titled “Related”- Queues; the BullMQ + QueueManager spine in depth.
- Background work; the architecture story.
- Audit log; recording lifecycle events fire-and-forget.
- Lint as the contract; the BullMQ plugin that enforces this shape.