Queues are one of the first backend tools that make an app feel serious.

You use them when work should happen outside the request-response path:

  • Sending emails.
  • Processing uploads.
  • Charging invoices.
  • Syncing data.
  • Running scheduled jobs.
  • Retrying fragile API calls.

In Node.js, Redis-backed queues are common because Redis is fast, familiar, and easy to run. The question is whether you should use BullMQ, older Bull, or raw Redis commands.

Quick answer

Use BullMQ for most new Node.js background job systems.

Use Bull only when an existing project already depends on it and it is working.

Use raw Redis only for very small, low-risk queues where losing or duplicating a job would not hurt users.

Choice Best when Avoid when
BullMQ You need retries, delays, workers, job state, and observability You want to learn raw queue mechanics from scratch
Bull You maintain an older app already using it You are starting a new project
Raw Redis The job is simple and non-critical Money, email, inventory, billing, or user trust is involved

BullMQ

BullMQ is the modern successor in the Bull family. It supports workers, retries, delays, priorities, repeatable jobs, rate limiting patterns, events, and job state tracking.

The shape is straightforward:

import { Queue, Worker } from "bullmq";

const emailQueue = new Queue("email", {
  connection: { host: "localhost", port: 6379 }
});

await emailQueue.add("welcome", {
  userId: "user_123"
});

new Worker("email", async (job) => {
  await sendWelcomeEmail(job.data.userId);
}, {
  connection: { host: "localhost", port: 6379 }
});

This gives you real queue behavior without inventing job state management yourself.

For production work, configure retries deliberately instead of accepting an accidental retry policy:

await emailQueue.add(
  "welcome",
  { userId: "user_123" },
  {
    jobId: "welcome:user_123",
    attempts: 5,
    backoff: { type: "exponential", delay: 1000 },
    removeOnComplete: 500,
    removeOnFail: 1000
  }
);

The stable jobId prevents the producer from adding the same welcome job twice while that ID still exists. The worker still needs idempotent business logic because queues can deliver a job again after a crash or retry.

Bull

Bull is the older library many Node projects used before BullMQ became the better default.

It can still work. If a production codebase already uses Bull and it is stable, migrating just to feel modern may not be urgent.

But for a new project, BullMQ is usually the cleaner choice because it is actively designed around the newer architecture and API.

Raw Redis queues

A raw Redis queue might start like this:

await redis.lpush("jobs:email", JSON.stringify({
  type: "welcome",
  userId: "user_123"
}));

const item = await redis.brpop("jobs:email", 0);

This is simple and useful for learning.

But production queues need more than push and pop:

  • What happens when a worker crashes after popping a job?
  • How do retries work?
  • Where do failed jobs go?
  • How do delayed jobs run?
  • How do you prevent duplicate processing?
  • How do you inspect job status?
  • How do you safely shut down workers?

If you build those features, you are building a queue library.

Comparison table

Capability BullMQ Bull Raw Redis list
Retries and backoff Built in Built in You design it
Delayed jobs Built in Built in You design scheduler storage
Stalled-job recovery Built in Built in You design leases and recovery
Events and job states Built in Built in You design schemas and retention
Current default for new apps Yes Usually no Only for narrow, non-critical work
Operational ownership Business logic and configuration Legacy library plus business logic Queue semantics, tooling, and business logic

When BullMQ is the right choice

Choose BullMQ when you need:

  • Reliable retries.
  • Multiple workers.
  • Delayed jobs.
  • Repeatable jobs.
  • Job status inspection.
  • Failure handling.
  • Rate limiting patterns.
  • A maintained Node queue abstraction.

This covers most app-level background job needs.

When raw Redis is enough

Raw Redis may be enough when:

  • The job is not critical.
  • Losing a job is acceptable.
  • You have one worker.
  • There are no payment, billing, inventory, or user-trust side effects.
  • You are building a learning project.

The moment the queue affects money, user communication, or permanent records, reach for a real queue abstraction.

The payment-worker test

Suppose a worker charges an invoice and crashes after the payment provider succeeds but before BullMQ records completion. A retry can run the handler again. The queue cannot know whether the external charge happened.

The worker needs an idempotency key and a durable record:

new Worker("invoice", async (job) => {
  const invoice = await db.invoice.findUniqueOrThrow({
    where: { id: job.data.invoiceId }
  });

  if (invoice.status === "paid") return;

  const charge = await payments.charge({
    amount: invoice.amount,
    idempotencyKey: `invoice:${invoice.id}`
  });

  await db.invoice.update({
    where: { id: invoice.id },
    data: { status: "paid", chargeId: charge.id }
  });
});

This is the important distinction: BullMQ handles job delivery and retry state; your application handles the meaning of doing the work twice.

Operational checklist before launch

  • Set retry counts and backoff from the failure you expect.
  • Keep failed jobs long enough to investigate, but cap retention.
  • Log the job ID, attempt number, and business entity ID together.
  • Make side effects idempotent before enabling retries.
  • Handle graceful worker shutdown during deployment.
  • Alert on queue age and repeated failures, not only queue length.
  • Test a worker crash after the external side effect succeeds.

The hidden question

The real decision is not “Which library is coolest?”

The real decision is:

How much queue behavior do I want to own?

If you choose raw Redis, you own the semantics. If you choose BullMQ, you still own your business logic, but the queue mechanics are handled for you.

For most Node apps, that is a good trade.

Portfolio angle

A queue comparison post is strong technical content because it proves you understand operational tradeoffs, not just syntax.

If you have built a queue-backed feature, document the decision:

  • Why did work need to move out of the request?
  • What failure modes mattered?
  • How did retries work?
  • What would you change at larger scale?

That kind of writing is more valuable than another todo app screenshot.

Official references