Skip to content
BoringStack
GitHub

Recipe: Add a background job

5 min read

Verified 2026-05

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.

  • 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:

Queue Directory Structure
src/queues/webhook-fanout/
  • 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.

  1. Create the directory and copy the file skeleton from email-delivery/:

    Terminal window
    cd apps/api && mkdir -p src/queues/webhook-fanout
    cp src/queues/email-delivery/email-delivery.*.ts \
    src/queues/webhook-fanout/
    # then rename the copied files from email-delivery.* to webhook-fanout.*
  2. 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>;
    }
  3. 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;
  4. 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);
    }
  5. 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.

  6. Wire the queue and worker into webhook-fanout.setup.ts, then register the setup function in src/config/setup-queues.ts alongside the existing queues (email-delivery, notification-dispatch, etc.).

  7. 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.

  • Boot the optional Bull Board overlay to watch jobs:

    Boot Bull Board Overlay
    $ 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 test or 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.

  • 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 the enqueueWebhookFanout(...) 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.