← Back to blog

Operating a Notification System with BullMQ, Part 1: What Is BullMQ?

·Updated

Introduction

Every service needs to send messages to users, whether through AlimTalk, email, or push notifications. But implementing notification delivery as a simple HTTP call quickly runs into limits.

  • Multiple domains call the same external API. Delivery paths become scattered across the API server, admin system, and batch jobs.
  • External APIs have rate limits. A constraint such as “100 requests per five seconds” must be enforced somewhere.
  • Many messages are not sent immediately. Timing branches such as “send guidance on D+1 after verification” or “send another notice three days after payment failure” are necessary. Some requirements, however, are difficult to represent with a state value—for example, a one-off message that an administrator schedules for a particular user at a particular time and that does not map naturally to the domain model.
  • Bulk delivery always includes some failures. The system must track which messages failed and need to be retried.

Each of these problems can be solved with a different tool. We can consolidate delivery into one module, put a rate limit inside a function, create time-based branches with a scheduler, and record failures in a delivery-history table. It is possible.

But when all four problems must be solved within one system—and must work consistently in a distributed environment consisting of an API server and multiple Worker instances—we need a separate infrastructure abstraction. A queue is one tool we can choose at that point. This article explains why I chose BullMQ and how I use it in practice.

What Is BullMQ?

BullMQ is a Redis-backed job-queue library for Node.js. A Producer adds a Job to a queue, and a separate Worker process takes the Job from the queue and processes it.

There are only three core concepts:

  • Queue — A logical channel in which Jobs are stored. Queues are separated by Redis key prefixes.
  • Job — A unit of work to process. It contains a payload and options such as delay and attempts.
  • Worker — A process that handles Jobs. It subscribes to a queue and processes Jobs asynchronously.
// Producer
await queue.add('send-alimtalk', { userId, template }, { delay: 60_000 });

// Worker
new Worker('notification', async (job) => {
  await alimtalkService.send(job.data);
}, { connection, limiter: { max: 20, duration: 1000 } });

How Does It Work?

The key to BullMQ is that Redis stores the entire state of the queue. The Jobs created by Producers, the list of Jobs waiting to be processed, the Jobs waiting for delayed delivery, and the locks on Jobs currently being processed all live in Redis. Workers continuously inspect Redis and take Jobs to process.

This architecture enables two things.

Producers and Workers are completely decoupled. A Producer is responsible only for creating a Job and putting it in Redis. A Worker is responsible only for taking a Job from Redis and processing it. They do not need to be in the same process or even on the same server. They only need to use the same Redis instance.

State remains consistent even when multiple Worker instances are running. A Job being processed is marked with a Redis lock so that another Worker cannot pick it up at the same time. The rate-limiter counter is also stored in Redis, so all Workers enforce one combined limit. “All shared state lives in Redis” is the principle behind BullMQ’s distributed behavior.

A Job passes through several states within a queue.

stateDiagram-v2
    accTitle: Core BullMQ job states and transitions
    accDescr: A job enters waiting immediately or delayed until its scheduled time, becomes active when a worker picks it up, and ends as completed or failed unless a retry returns it to waiting.
    [*] --> Waiting: enqueue
    [*] --> Delayed: enqueue with delay
    Delayed --> Waiting: scheduled time reached
    Waiting --> Active: worker picks up job
    Active --> Waiting: error and retries remain
    Active --> Completed: successful processing
    Active --> Failed: error and retries exhausted
    Completed --> [*]
    Failed --> [*]

wait means waiting to be processed, delayed means waiting for its scheduled time, active means processing, and completed and failed are terminal states. These states are represented by Redis data structures such as lists, sorted sets, and hashes. State transitions are performed atomically through Lua scripts, which prevents multiple Workers from picking up the same Job simultaneously.

Architecture

  • The lifecycle followed by a Job added through Queue.add()
  • A Job in delayed does not move directly to active when its time arrives. Internally, it is first promoted to wait or prioritized, then enters active.
stateDiagram-v2
    accTitle: Lifecycle of a BullMQ job added with Queue.add
    accDescr: A newly added job is routed by delay and priority into delayed, waiting, or prioritized storage, becomes active when a worker can process it, and then completes, fails, or returns for another attempt.
    [*] --> Added: job added
    Added --> delayChoice
    state delayChoice <<choice>>
    delayChoice --> Delayed: delay > 0
    delayChoice --> priorityChoice: delay = 0
    Delayed --> priorityChoice: scheduled time reached
    state priorityChoice <<choice>>
    priorityChoice --> Waiting: priority = 0
    priorityChoice --> Prioritized: priority > 0
    Waiting --> Active: worker available and within limits
    Prioritized --> Active: worker available and within limits
    Active --> Waiting: moveToWait or dynamic rate limit
    Active --> Delayed: moveToDelayed or backoff retry
    Active --> outcomeChoice: processing finishes
    state outcomeChoice <<choice>>
    outcomeChoice --> Completed: no error
    outcomeChoice --> Failed: unrecoverable error or attempts exhausted
    Completed --> [*]
    Failed --> [*]
  • The lifecycle of a Job added through FlowProducer.add()
  • waiting-children means “waiting for someone else to finish processing.
  • When the final child completes, the parent is promoted to wait, prioritized, or delayed.
stateDiagram-v2
    accTitle: Lifecycle of a BullMQ parent job added with FlowProducer.add
    accDescr: A parent job waits for its children when dependencies exist, then follows the normal delay, priority, active, completion, failure, and retry states after the children finish.
    [*] --> Added: job added
    Added --> childrenChoice
    state childrenChoice <<choice>>
    childrenChoice --> WaitingChildren: child jobs exist
    childrenChoice --> delayChoice: no child jobs
    WaitingChildren --> delayChoice: all children completed
    WaitingChildren --> Failed: child failed with failParentOnFailure
    state delayChoice <<choice>>
    delayChoice --> Delayed: delay > 0
    delayChoice --> priorityChoice: delay = 0
    Delayed --> priorityChoice: scheduled time reached
    state priorityChoice <<choice>>
    priorityChoice --> Waiting: priority = 0
    priorityChoice --> Prioritized: priority > 0
    Waiting --> Active: worker available and within limits
    Prioritized --> Active: worker available and within limits
    Active --> WaitingChildren: moveToWaitingChildren
    Active --> Waiting: moveToWait or dynamic rate limit
    Active --> Delayed: moveToDelayed or backoff retry
    Active --> outcomeChoice: processing finishes
    state outcomeChoice <<choice>>
    outcomeChoice --> Completed: no error
    outcomeChoice --> Failed: unrecoverable error or attempts exhausted
    Completed --> [*]
    Failed --> [*]

In BullMQ, a Job is a state machine.

The moment Queue.add() is called, the Job enters its lifecycle and moves through several states before ending in completion or failure. If a failed Job is retried, it begins a new lifecycle. The important point is that BullMQ does not manage “state” as a simple enum. It manages state by placing Job IDs in different Redis data structures.

State Data structure Description
wait LIST(bull:queue:wait) Natural representation of a FIFO queue; atomically pops and moves a Job to active with BRPOPLPUSH
prioritized ZSET Uses the priority value as the score for automatic ordering
delayed ZSET Uses the execution timestamp as the score for time-based ordering
active LIST Set of Jobs being processed, used to detect stalled Jobs if a Worker dies
completed/failed ZSET Uses completion time as the score and supports cleanup of old Jobs with removeOnComplete
waiting-children ZSET Special state for parent-child dependencies

Core BullMQ Features

1. Delayed Jobs

When you add a Job with the delay option, a Worker cannot pick it up until the specified time has passed.

// Send three days later
await queue.add('reapply-guide', payload, { delay: 3 * 24 * 60 * 60 * 1000 });

Internally, BullMQ stores the Job in a Redis sorted set with its timestamp as the score. A separate scheduler moves it from delayed to wait.

2. Rate Limiter

The limiter option on a Worker enforces the number of Jobs processed per unit of time.

new Worker(name, handler, {
  limiter: { max: 20, duration: 1000 }, // 20 per second
});

The counter is stored in Redis, and the limit applies to the combined total even when multiple Worker instances are running. This makes it possible to comply precisely with an external API’s rate limit.

3. Deduplication

When you specify a jobId, a Job with that same ID can enter the queue only once.

BullMQ stores Job data in a Redis Hash with the key format bull:{queueName}:{jobId}. If you do not specify a jobId, BullMQ calls INCR on the bull:{queueName}:id counter to issue monotonically increasing integers: 1, 2, 3, and so on.

When you specify a jobId, the addJob.lua script atomically performs the following sequence:

1. EXISTS bull:{queueName}:{jobId} — Does the Hash key exist?
2. It exists → do not push it to the queue (wait/delayed/prioritized); return the existing jobId and emit a duplicated event.
3. It does not exist → create the Hash with HSET and push the jobId to the queue.

This also matters when the Job has already completed successfully.

You can specify a jobId as follows:

await queue.add(name, data, { jobId: `payment-request-${paymentId}` });

4. Retry and Backoff

When processing throws an error, BullMQ retries automatically. You can configure attempts and a backoff strategy such as fixed or exponential.

await queue.add(name, data, {
  attempts: 3,
  backoff: { type: 'exponential', delay: 5000 },
});

5. Concurrency

This setting specifies how many Jobs a Worker can process simultaneously.

For I/O-heavy work such as calling external APIs, increasing concurrency is a good way to improve throughput.

new Worker(name, handler, { concurrency: 50 });

6. Flows

FlowProducer can create a graph of Jobs with parent-child relationships. A parent Job remains in waiting-children until every child completes, and only then is it processed.

import { FlowProducer } from 'bullmq';

const flow = new FlowProducer({ connection });

await flow.add({
  name: 'aggregate-results',
  queueName: 'order',
  data: { batchId: 'b-2026-01-15' },
  children: [
    { name: 'item', queueName: 'item-queue', data: { chunk: 1 } },
    { name: 'order', queueName: 'order-queue', data: { chunk: 2 } },
    { name: 'notification', queueName: 'notification-queue', data: { chunk: 3 } },
  ],
});

This model is natural when a large operation must be divided into chunks and the results aggregated after every chunk finishes, or when representing a multistage pipeline such as “preprocessing → delivery → post-processing.” If even one child fails, the parent is not processed, allowing a workflow with explicit dependencies to be managed safely.

BullMQ Is a Job Queue: How It Differs from Event-Driven Architecture

Because processing is asynchronous, something queue-like sits in the middle, and coupling is reduced, BullMQ may look like an event-driven system. Once you use it seriously, however, it becomes clear that BullMQ follows a different model.

Does the Producer Know the Consumer?

In a job queue, the Producer explicitly names the queue and the Job in code.

await queue.add('send-alimtalk', { userId, template });

The Producer code explicitly knows that “someone exists to process a Job named send-alimtalk.” The Producer is entirely responsible for deciding which queue and payload to use. It delegates the work directly.

The event model is different.

eventBus.emit('user.signed-up', { userId });

The Publisher emits only the fact that “a user signed up.” Who listens and what they do are not the Publisher’s concern. An analytics system may listen, a notification system may listen, or nobody may listen. Adding a new Listener does not change the Publisher code. This is the essence of event-driven architecture.

If your goal is truly event-driven architecture, RabbitMQ or Kafka may be a better choice.

Note: BullMQ Events
What BullMQ calls an “event” is a callback mechanism for observing internal changes in queue and Worker state. You can receive lifecycle events such as a Job completing, failing, becoming stalled, or updating its progress.

When Should You Choose BullMQ?

BullMQ may be the better choice in situations like these:

  • Delivery triggers are distributed across multiple domains.
  • You must comply strictly with an external API’s rate limit.
  • You want to delegate work to horizontally scalable servers.
  • You already operate Redis and use Node.js.
  • You want the powerful retry logic provided at the library level, such as retrying only failed messages.

BullMQ Overview for the Notification Server

1. Consolidate Distributed Delivery Paths into One Queue

Delivery triggers occur everywhere: the API server, admin system, batch jobs, and more.

Put all of them into the same notification queue and delegate delivery to a separate Worker service.

flowchart LR
    accTitle: Notification triggers converge on one BullMQ queue
    accDescr: The API server, admin system, and batch jobs enqueue notification work, multiple workers share that queue, and each worker calls the same external delivery API.
    api["API server"] --> queue[("notification queue")]
    admin["Admin system"] --> queue
    batch["Batch jobs"] --> queue
    queue --> worker1["Worker 1"]
    queue --> worker2["Worker 2"]
    queue --> worker3["Worker 3"]
    worker1 --> external["External API"]
    worker2 --> external
    worker3 --> external

Manage the delivery logic—template mapping, phone-number normalization, and external API calls—in one place.

If throughput is insufficient, add more Worker instances.

Multiple Workers subscribed to the same queue automatically divide the work. Because the rate limiter shares a Redis-backed counter, the total number of calls to the external API remains consistently within the limit whether you have one instance or ten.

2. Control the External API Rate Limit with the Worker Limiter

If the external API allows “100 requests per five seconds,” configure the Worker limiter as { max: 20, duration: 1000 }.

Twenty requests per second accumulate to 100 requests over five seconds, keeping the system within the limit.

Even when traffic spikes, requests merely accumulate in the queue and are smoothed out; the external API remains unaffected.

3. On a Paid Plan, Use Batch Processing for Bulk Requests

import { WorkerPro } from '@taskforcesh/bullmq-pro';

const worker = new WorkerPro('alimtalk-queue', async (job: JobPro) => {
  const batch = job.getBatch();           // Multiple Jobs arrive as an array
  for (const batchedJob of batch) {
    await sendAlimtalk(batchedJob.data);
  }
}, {
  connection,
  batch: { 
    size: 10,        // Up to 10 Jobs in one batch
    minSize: 5,      // Wait until at least 5 Jobs have accumulated
    timeout: 30000,  // Process after 30 seconds even if fewer than 5 arrive
  }
});

If the external API you use has a strict rate limit, consider sending bulk requests through Batch.

Be sure to evaluate tradeoffs such as partial failures and a larger retry unit.

Next

We have learned what BullMQ is and used its features to sketch a simple Notification server design.

The next article will examine the incidents and tradeoffs I encountered while operating it.

References