Blog
Running Background Jobs in Node with BullMQ and Smart Retries
BullMQ turns Redis into a production-grade job queue for Node, giving you retries, backoff, scheduling, and concurrency without standing up a separate broker.
Not every system needs RabbitMQ or Kafka. If you already run Redis and live in the Node ecosystem, BullMQ gives you a powerful background job queue with very little ceremony. It is the modern, TypeScript-first successor to the original Bull library, and it handles the things that make background work hard: retrying failed jobs with backoff, scheduling delayed and repeated jobs, limiting concurrency, and reporting progress. For a MERN developer, BullMQ feels natural because the API is plain JavaScript and the persistence layer is the Redis you probably already deploy. This article walks through how BullMQ models work, how its retry and backoff machinery behaves, and the operational details, like graceful shutdown and stalled-job recovery, that separate a toy queue from one you trust in production. By the end you will know how to offload slow work safely and watch it complete reliably.
Queues Workers And Jobs
BullMQ organizes everything around three objects. A Queue is the producer-side handle you use to add jobs; calling add places a job into Redis with a name and a data payload. A Worker is the consumer; you give it the same queue name and a processor function, and it pulls jobs and runs them. A Job is the unit of work itself, carrying its data, options, attempt count, and lifecycle state. Producers and workers can live in entirely separate processes, which is the whole point: your web server adds a job and responds immediately, while a pool of worker processes churns through the heavy lifting in the background. This mirrors the RabbitMQ producer-consumer split but with a far smaller learning curve, because there are no exchanges or bindings to design. You just name a queue, add jobs to it, and run workers that consume them.
Redis As The Backbone
BullMQ stores all of its state in Redis, using atomic Lua scripts to move jobs between states without race conditions. Each queue maps to a set of Redis data structures: lists for waiting jobs, sorted sets for delayed and prioritized jobs, and hashes for job data. This design has real consequences. Because Redis is in-memory, BullMQ is fast and low-latency, but you must configure Redis persistence and memory limits thoughtfully, since losing Redis means losing jobs unless you have AOF or RDB durability enabled. You also need maxRetriesPerRequest set to null on the connection BullMQ uses, a well-known requirement that trips up newcomers. The upside is enormous: you get a robust queue without operating a separate broker, reusing infrastructure you already understand. Just remember that Redis is now a system of record for in-flight work, so treat its durability and availability as seriously as your database.
Configuring Retries And Attempts
When you add a job you can specify how many times BullMQ should attempt it before giving up. Set the attempts option to, say, three, and any job whose processor throws an error will be retried up to three times total before it is marked failed. Each failed attempt increments the job's attemptsMade counter, which your processor can read to behave differently on later tries. This built-in retry is one of BullMQ's biggest conveniences over rolling your own queue, because the redelivery logic, state transitions, and counting are all handled atomically in Redis. A job only lands in the failed set after exhausting its attempts, and it stays there with its error stack trace recorded so you can inspect why it died. Choosing the right attempt count depends on how transient your failures are; idempotent network calls tolerate more retries than operations with side effects.
Backoff Strategies That Matter
Retrying instantly is rarely helpful, so BullMQ lets you pair attempts with a backoff strategy. The fixed strategy waits a constant delay between attempts, while the exponential strategy doubles the wait each time, turning a base of one second into roughly one, two, then four seconds. You configure this in the backoff option alongside attempts. Exponential backoff is usually the right default because it gives a struggling downstream service progressively more room to recover instead of pounding it. BullMQ also supports custom backoff functions, so you can implement jitter to avoid many jobs retrying in lockstep, or vary the delay based on the specific error. The combination of bounded attempts and sensible backoff is what makes BullMQ's retry behavior production-grade, sparing you from the fragile delay-and-requeue logic that hand-rolled queues inevitably get subtly wrong.
Delayed And Repeatable Jobs
BullMQ does more than fire-and-forget. You can schedule a job to run after a delay by passing the delay option in milliseconds, which is perfect for sending a follow-up email an hour later or retrying an external sync at a future time. Internally these wait in a Redis sorted set keyed by their run time, and a worker promotes them to the active state when they come due. For recurring work, repeatable jobs let you define a cron pattern or a fixed interval, so BullMQ behaves like a distributed cron that survives restarts and runs exactly across a worker pool rather than on every server. This replaces a pile of node-cron timers scattered across instances, which is a common source of duplicate execution in clustered MERN deployments. With repeatable jobs you declare the schedule once and trust BullMQ to enqueue an instance at each tick.
Concurrency And Rate Limiting
A single BullMQ worker can process multiple jobs in parallel by setting its concurrency option, which controls how many jobs that one worker handles simultaneously. Combined with running several worker processes, this gives you two dimensions of scaling: parallelism within a process and horizontal scaling across processes. But unbounded concurrency can overwhelm a database or a third-party API, so BullMQ also offers rate limiting at the queue level, letting you cap throughput to, for example, a hundred jobs per minute. When the limit is hit, jobs wait rather than failing, which keeps you within an external service's quota without writing custom throttling. Tuning concurrency and rate limits together is how you match your job processing to the real capacity of the resources those jobs touch. Set concurrency too high and you trade queue speed for downstream instability; too low and jobs back up needlessly.
Events Progress And Observability
Knowing what your queue is doing is essential in production. BullMQ emits events for the full job lifecycle, completed, failed, progress, stalled, and more, which you can listen to with a QueueEvents instance subscribing to a Redis stream. Inside a processor, calling updateProgress lets long-running jobs report a percentage that your dashboard or UI can display, turning an opaque background task into something users can watch. For visibility without building your own dashboard, tools like Bull Board or Taskforce give you a UI over your queues showing waiting, active, completed, and failed counts. Wiring these up early pays off, because the first time a job mysteriously stalls you will want to see its state, its error, and its attempt history immediately. Treat the failed set like a dead letter queue: monitor its size, alert when it grows, and inspect and retry the jobs that land there.
Graceful Shutdown And Stalls
Two operational details separate a reliable BullMQ deployment from a flaky one. The first is graceful shutdown: when you deploy or scale down, call the worker's close method and await it so in-flight jobs finish instead of being abruptly killed mid-execution. Without this, a deploy can leave jobs half-done. The second is stalled-job handling. BullMQ detects when a worker grabs a job but never reports back, perhaps because the process crashed, by treating the job as stalled after a lock duration elapses, then making it available again for another worker. You can tune how long that grace period is and how many times a job may stall before being failed outright. Together, graceful shutdown and stall recovery ensure that crashes and deploys do not silently lose work, which is precisely the guarantee you need before you trust a background queue with anything that matters to users.