ZyVOP Logo
Content That Connects
SeriesAI NewsWhy ZyVOPJoin Discord
LoginGet Started
ZyVOP Logo
Content That Connects

The Developer Publishing Hub. Write once, cross-post to Dev.to, Medium, Hashnode, WordPress & Bluesky with automated canonical source tags and zero paywalls.

Content

  • Categories
  • Tags
  • Badges
  • Leaderboard
  • Write Article
  • Newsletter

Company

  • About Us
  • Why ZyVOP
  • Developer API & CLI
  • Write for Us
  • Contact

Connect

  • Privacy Policy
  • Terms of Service
  • Cookie Policy
  • DMCA Policy
  • Code of Conduct

ยฉ 2026 ZyVOP. Developer Publishing Hub.

Zero paywalls ยท Full content ownership
All systems operational
HomeStop Doing Slow Things in Request Handlers: Background Jobs with BullMQ

Stop Doing Slow Things in Request Handlers: Background Jobs with BullMQ

How to move emails, image processing, webhooks, and slow third-party calls out of your API and into a proper job queue

ZyVOP
ZyVOP
Senior Developer
May 25, 2026
6 min read
Stop Doing Slow Things in Request Handlers: Background Jobs with BullMQ
#background jobs Node.js 2026#job queue Express#BullMQ retry#BullMQ Node.js#BullMQ worker#BullMQ Redis
๐Ÿ‘1

Here is a rule of thumb that will save you a lot of production pain: if it takes more than 200ms, it should not be in your request/response cycle.

Sending a welcome email. Resizing an uploaded image. Generating a PDF report. Processing a webhook. Calling a slow third-party API. Every one of these is a candidate for a background job โ€” a task that your API kicks off and immediately returns 200 for, while a separate worker process handles the actual work asynchronously.

BullMQ is the standard job queue library for Node.js in 2026. It is built on Redis, handles retries, priorities, scheduled jobs, and failure tracking, and it is what most production Node.js backends are using for this problem. This guide walks through setting it up properly โ€” not just getting it running, but getting it production-ready.


The Architecture

API Server                    Redis                      Worker Process
โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€                 โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€              โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
POST /signup        โ”€โ”€addโ”€โ”€โ–บ  [ job queue ]  โ”€โ”€pickโ”€โ”€โ–บ   send welcome email
  โ””โ”€ return 201               [ job queue ]  โ”€โ”€pickโ”€โ”€โ–บ   resize avatar
                              [ job queue ]  โ”€โ”€pickโ”€โ”€โ–บ   sync to CRM

The API and the worker are separate processes. In production they can scale independently โ€” if you have an email backlog, you spin up more workers without touching your API servers.


Setup

npm install bullmq ioredis

BullMQ requires Redis 6.2 or higher.


Step 1 โ€” The Redis Connection

// src/lib/redis.js
import Redis from 'ioredis';

// BullMQ requires maxRetriesPerRequest: null
// This lets BullMQ manage its own retry logic without ioredis interfering
export const bullMQConnection = new Redis({
  host: process.env.REDIS_HOST || 'localhost',
  port: parseInt(process.env.REDIS_PORT || '6379'),
  password: process.env.REDIS_PASSWORD || undefined,
  maxRetriesPerRequest: null,   // Required for BullMQ
  enableReadyCheck: false,      // Required for BullMQ
});

Step 2 โ€” Define Your Queues

Keep queue definitions in one place so API and worker import the same instances:

// src/queues/index.js
import { Queue } from 'bullmq';
import { bullMQConnection } from '../lib/redis.js';

const defaultJobOptions = {
  attempts: 3,                          // Retry up to 3 times on failure
  backoff: {
    type: 'exponential',
    delay: 1000,                        // First retry after 1s, then 2s, then 4s
  },
  removeOnComplete: { count: 100 },     // Keep last 100 completed jobs for inspection
  removeOnFail: { count: 500 },         // Keep last 500 failed jobs for debugging
};

export const emailQueue = new Queue('email', {
  connection: bullMQConnection,
  defaultJobOptions,
});

export const imageQueue = new Queue('image-processing', {
  connection: bullMQConnection,
  defaultJobOptions: {
    ...defaultJobOptions,
    attempts: 2,                        // Image processing โ€” only retry twice
  },
});

export const reportQueue = new Queue('reports', {
  connection: bullMQConnection,
  defaultJobOptions: {
    ...defaultJobOptions,
    attempts: 1,                        // Reports are expensive โ€” don't auto-retry
  },
});

Step 3 โ€” Adding Jobs from Your API

// src/routes/auth.js
import { emailQueue } from '../queues/index.js';

router.post('/signup', async (req, res) => {
  const user = await createUser(req.body);

  // Kick off the email job and return immediately
  // Do NOT await this โ€” you don't want to hold the response
  await emailQueue.add(
    'welcome-email',                  // Job name โ€” use for filtering in dashboard
    {
      userId: user.id,
      email: user.email,
      name: user.name,
    },
    {
      // Override defaults for this specific job
      priority: 1,                    // Lower number = higher priority
      delay: 2000,                    // Wait 2s before processing (let DB writes settle)
    }
  );

  res.status(201).json({ id: user.id });
  // Email sends in background โ€” user already has their 201
});

// Adding a scheduled/recurring job (runs every day at 8am)
import { emailQueue } from '../queues/index.js';

await emailQueue.add(
  'daily-digest',
  { type: 'daily-digest' },
  {
    repeat: {
      pattern: '0 8 * * *',          // Cron syntax
      tz: 'UTC',
    },
  }
);

Step 4 โ€” The Worker

The worker is a separate process. It connects to the same Redis, picks up jobs, and processes them.

// src/workers/email.worker.js
import { Worker } from 'bullmq';
import { bullMQConnection } from '../lib/redis.js';
import { sendWelcomeEmail, sendDailyDigest } from '../services/email.js';

const emailWorker = new Worker(
  'email',
  async (job) => {
    // job.name is the name you passed in add()
    // job.data is the payload
    // job.id is the unique job ID
    // job.attemptsMade is how many times this has been tried

    console.log(`Processing job ${job.id}: ${job.name} (attempt ${job.attemptsMade + 1})`);

    switch (job.name) {
      case 'welcome-email':
        await sendWelcomeEmail({
          to: job.data.email,
          name: job.data.name,
        });
        // Update job progress (visible in dashboard)
        await job.updateProgress(100);
        return { sent: true, recipient: job.data.email };

      case 'daily-digest': {
        const users = await getUsersForDigest();
        let sent = 0;
        for (const user of users) {
          await sendDailyDigest(user);
          sent++;
          // Report progress as percentage
          await job.updateProgress(Math.round((sent / users.length) * 100));
        }
        return { sent, total: users.length };
      }

      default:
        throw new Error(`Unknown job name: ${job.name}`);
    }
  },
  {
    connection: bullMQConnection,
    concurrency: 5,               // Process up to 5 jobs simultaneously
    limiter: {
      max: 10,                    // Max 10 jobs per duration
      duration: 1000,             // Per second โ€” rate limits your email provider calls
    },
  }
);

// Event listeners for observability
emailWorker.on('completed', (job, result) => {
  console.log(`Job ${job.id} completed:`, result);
});

emailWorker.on('failed', (job, err) => {
  console.error(`Job ${job?.id} failed (attempt ${job?.attemptsMade}):`, err.message);
  // Send to your error tracker (Sentry, etc.)
  captureError(err, { jobId: job?.id, jobName: job?.name, data: job?.data });
});

emailWorker.on('stalled', (jobId) => {
  // Job was picked up by a worker that died before completing
  // BullMQ automatically re-queues stalled jobs
  console.warn(`Job ${jobId} stalled โ€” will be requeued`);
});

// Graceful shutdown โ€” finish current jobs before exiting
process.on('SIGTERM', async () => {
  console.log('Worker shutting down...');
  await emailWorker.close();
  process.exit(0);
});

Step 5 โ€” Running Workers in Docker

In production, run the worker as a separate container. It shares code with your API but has a different entrypoint.

# Dockerfile (shared between API and worker)
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
# docker-compose.yml (relevant section)
services:
  api:
    image: your-app
    command: node src/server.js
    # ...

  worker-email:
    image: your-app
    command: node src/workers/email.worker.js
    restart: unless-stopped
    depends_on:
      - redis
    environment:
      REDIS_HOST: redis
    # Scale workers independently during high load:
    # docker compose up --scale worker-email=5

Step 6 โ€” The Dashboard

BullMQ has an official UI called Bull Board. It shows queue depths, failed jobs, retry buttons, and job payloads โ€” essential for debugging production issues.

npm install @bull-board/express @bull-board/api
// src/admin/queues.js
import { createBullBoard } from '@bull-board/api';
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter.js';
import { ExpressAdapter } from '@bull-board/express';
import { emailQueue, imageQueue, reportQueue } from '../queues/index.js';

const serverAdapter = new ExpressAdapter();
serverAdapter.setBasePath('/admin/queues');

createBullBoard({
  queues: [
    new BullMQAdapter(emailQueue),
    new BullMQAdapter(imageQueue),
    new BullMQAdapter(reportQueue),
  ],
  serverAdapter,
});

export default serverAdapter;
// Mount it in your app โ€” behind admin auth
import queueDashboard from './admin/queues.js';

app.use(
  '/admin/queues',
  authenticate,
  requireRole('admin'),
  queueDashboard.getRouter()
);

Step 7 โ€” Idempotency (The Part Everyone Forgets)

Jobs can run more than once. A worker can crash mid-job, BullMQ re-queues it, and the job runs again. Your job handlers must be idempotent โ€” running them twice should produce the same result as running them once.

For emails, use a deduplication key:

await emailQueue.add(
  'welcome-email',
  { userId: user.id, email: user.email },
  {
    jobId: `welcome-${user.id}`,   // Unique ID prevents duplicate jobs for the same user
  }
);

For database writes, use ON CONFLICT DO NOTHING or check before inserting:

// In your worker โ€” safe to run twice
await db.query(`
  INSERT INTO email_logs (user_id, type, sent_at)
  VALUES ($1, $2, NOW())
  ON CONFLICT (user_id, type) DO NOTHING
`, [job.data.userId, 'welcome']);

Monitoring Queue Health

A queue that is growing faster than it is draining is a production incident waiting to happen. Track queue depth as a metric:

// Health check endpoint โ€” include queue depths
router.get('/health', async (req, res) => {
  const [emailCounts, imageCounts] = await Promise.all([
    emailQueue.getJobCounts(),
    imageQueue.getJobCounts(),
  ]);

  const isHealthy =
    emailCounts.waiting < 1000 &&
    emailCounts.failed < 50;

  res.status(isHealthy ? 200 : 503).json({
    status: isHealthy ? 'ok' : 'degraded',
    queues: {
      email: emailCounts,
      image: imageCounts,
    },
  });
});

getJobCounts() returns { waiting, active, completed, failed, delayed, paused }. If waiting is climbing and active is not increasing, your workers are not keeping up โ€” scale them.


What to Queue vs What Not To Queue

Queue it:

  • Sending any kind of notification (email, SMS, push)

  • Image or video processing

  • PDF generation

  • Webhooks to third-party services

  • Syncing data to external systems (CRM, analytics)

  • Expensive report generation

  • Anything that calls a rate-limited API

Do not queue it:

  • Simple database reads

  • Cache lookups

  • Anything the user is actively waiting for in the same session

  • Sub-10ms operations โ€” the queue overhead costs more than the work

The rule is not "make everything async." It is "make the right things async." When you queue the right work, your API becomes dramatically more resilient โ€” a slow email provider no longer means slow signups, and a failed third-party webhook no longer loses data.

Comments (0)

Login to post a comment.

ZyVOP
ZyVOP

Founder of Zyvop ๐Ÿš€ | Building AI-driven tools & premium insights for software engineers, CTOs, and tech leaders. Obsessed with automating workflows and exploring the frontier of AI.

Subscribe to ZyVOP's Newsletter

More from ZyVOP

View profile

Debian Adopts "Responsible Use of Generative AI" After Nine-Way Condorcet Vote

Debian's General Resolution 2026-002 closed on August 28 with "Responsible Use of Generative AI" beating eight rival proposals, including a Social Contract ban, by a clear Condorcet margin, per the project secretary's published beat matrix.

3 minAug 30

How I Built a Real-Time Developer Trend Radar Into My SEO Growth Engine

An AI-powered content intelligence system that streams live developer conversations from Hacker News, Dev.to, Google Search, and GitHub โ€” and turns them into ready-to-write blog opportunities with one click.

12 minAug 29

Qwen3.8-Flash-Next Cost Efficiency, OpenExecutive Satire, and Multi-Vector Retrieval Advances

This week's digest covers Qwen3.8-Flash-Next's push for ultimate cost-efficiency, the viral OpenExecutive project, and the technical release of MultiVectorEncoder in Sentence-Transformers v6.0.

4 minAug 28

Anthropicโ€™s Pricing Shock, Granite 4.2 Openโ€‘Source Leap, and AIโ€‘Powered Security & Policy Shifts

From Anthropicโ€™s flagship model losing steam to IBMโ€™s 512โ€ฏKโ€‘token Graniteโ€ฏ4.2, plus a new wave of AIโ€‘driven security exploits and policy alarms, this weekโ€™s digest maps the technical and market forces you need to act on now.

3 minAug 26

Introducing Questions and Discussions: A New Way to Connect!

We are thrilled to announce a major update to how you can interact and share content on our platform! Up until now, sharing your thoughts meant writing a standa...

2 minAug 1