From Cron Jobs to BullMQ: Building Reliable Background Processing at Scale

Engineering Aug 19, 2026
TL;DR

We started with cron jobs because they were simple, familiar, and fast to set up. They worked well when our background tasks were small and predictable. But as our product grew, the work became more varied: recurring syncs, one-time scheduled runs, long-running workflows, retries, failures, operational visibility, and jobs that needed to run across services.

That is where BullMQ became the better fit. It gave us a real queue-based system with scheduling, retries, backoff, delayed jobs, concurrency, and clearer tracking of what happened to every job. Cron helped us begin. BullMQ is helping us scale the next stage.

The Early Days: Cron Was Enough

In the beginning, background work was simple.

Some tasks had to run every day. Some had to refresh data at fixed intervals. Some had to clean up old records. Cron was the obvious choice. It was easy to understand: “run this task every few minutes,” or “run this once a day.”

And honestly, that was the right decision at the time.

Cron is great when the job is predictable, short-lived, and not too sensitive to failure. You write the logic, set the schedule, and let it run. For a young system, that speed matters. You do not want to over-engineer something before you know what the real workload looks like.

But over time, our background work stopped being just “scheduled tasks.” It became a proper workload of its own.

Where Cron Started to Struggle

The first cracks were not dramatic. They were small, practical things.

A customer scheduled a data sync every hour. One run took longer than expected because an external API slowed down. Before that run finished, the next scheduled sync started. Now two workers were trying to update the same dataset.

A long-running task would overlap with the next scheduled run. A job would get stuck halfway, and someone would need to understand whether it was still running, failed, or safe to restart. Some work needed to happen once in the future, not repeatedly. Some work needed to be triggered manually without disturbing the normal schedule.

Cron can start a task, but it does not naturally remember much about the task after that. It does not give you job history, attempts, retry behavior, delayed execution, or a built-in way to distribute work across multiple workers.

We could keep adding patches around cron: locks, status records, retry logic, stale job recovery, custom dashboards, manual controls. But at some point, those patches begin to look like a queue system built by hand.

Looking back, this was the point where cron was no longer the right abstraction for the kind of work we were running.

The Shape of Our Work Changed

The important shift was that our jobs became more than timed functions.

We had recurring data syncs that needed to run reliably without overlapping. We had pipeline-style work that could be scheduled by users or triggered immediately. We had long-running agent tasks that needed clear state and durable outcomes. We had future one-time jobs where “late is better than lost.” We had maintenance tasks that should quietly keep the system healthy in the background.

These are all slightly different use cases, but they share the same core needs:

  • Put work into a queue
  • Run it when capacity is available
  • Retry it when something temporary fails
  • Track each attempt
  • Avoid duplicate execution
  • Scale workers without rewriting scheduling logic
  • Keep enough history to debug what happened

As our workload grew, execution became more important than scheduling. That's where a queue-based system fit better.

That distinction became very important.

What Is BullMQ?

BullMQ is a Node.js job queue built on top of Redis. It helps applications move work out of the request path and into background workers.

Instead of directly running a heavy task, the application adds a job to a queue. A worker picks it up, processes it, and reports the result. If the job fails, BullMQ can retry it. If it should run later, BullMQ can delay it. If it should repeat on a schedule, BullMQ can manage that too.

The main advantage is that BullMQ treats background work as a first-class system, not as a side effect of a timer.

It gives us useful building blocks:

  • Queues for organizing different kinds of work
  • Workers for processing jobs independently
  • Retries with backoff for temporary failures
  • Delayed jobs for one-time future execution
  • Repeatable jobs for recurring schedules
  • Concurrency controls for safer parallel processing
  • Redis-backed durability for job state
  • Events and job lifecycle hooks for better observability

In simple words: BullMQ lets us say, “This work matters. Track it, retry it, and run it safely.”

Why BullMQ Fits Our Stage Right Now

We are at the stage where background jobs are no longer a small utility. They are part of the product experience.

When a user schedules a workflow, they expect it to run. When a sync fails because an external API had a temporary issue, we should retry it. When a task is long-running, we need to know whether it is active, completed, failed, or waiting for another attempt. When we deploy or restart workers, pending work should not simply disappear.

BullMQ fits this stage because it gives us stronger guarantees without forcing a huge platform rewrite.

It also works well with the architecture we already have. We can keep our application-owned schedule definitions and business rules, while BullMQ handles dispatching, delayed execution, retries, and worker consumption. That means we get the reliability of a queue without losing control over how jobs are created, paused, resumed, or displayed.

This is especially useful for us because our background work is not all the same. Some jobs are system-owned and recurring. Some are user-created. Some run once. Some run immediately. Some are processed by one service, while others are consumed by another. BullMQ gives us a common backbone for all of them.

The Biggest Win: Operational Clarity

One of the biggest benefits is not just that jobs run. It is that we can see them run.

With cron, a job can feel invisible. You may know it was supposed to run, but debugging often depends on logs and guesswork. With a queue-based system, each job can have a lifecycle: created, started, completed, failed, retried, or moved aside for manual review.

In practice, this means production incidents become easier to understand.

Instead of searching through logs to work out whether a scheduled task ran, we can answer questions like:

  • Which job ran?
  • How long did it take?
  • How many times was it attempted?
  • What failed?
  • Is it going to retry?
  • Was this manually triggered or scheduled?
  • Is this a recurring run or a one-time run?

That kind of visibility matters as the number of background tasks grows.

Retries Matter More Than We Expected

A lot of background jobs depend on things outside our control: third-party APIs, network calls, rate limits, database load, or temporary service failures.

Cron does not naturally understand the difference between a permanent failure and a temporary one. It simply runs again at the next scheduled time. That might be too late, too soon, or not structured enough.

BullMQ lets us retry failed jobs with backoff. That means a job can fail, wait a bit, try again, wait longer, and only then be marked as permanently failed if it still cannot recover.

This is a much more realistic model for modern systems. Many failures are not final. They are just badly timed.

One-Time Jobs Needed a Better Home

Recurring jobs are only part of the story.

Sometimes work needs to happen once at a specific future time. For example, a user may schedule a workflow to run later, or the system may need to defer a task until a known moment.

Cron is awkward for this. You can simulate it, but it usually involves polling, checking timestamps, and making sure completed work does not accidentally run again.

BullMQ handles delayed jobs naturally. A job can be added now and processed later. If the worker is down at the exact moment it should run, it can still be picked up when the system comes back. For us, that behavior is important: late is acceptable; lost is not.

Scaling Without Double Running Everything

As systems scale, cron creates a classic problem.

If every application instance runs the scheduler, the same job may execute multiple times. If only one instance runs it, that instance becomes special and needs careful handling.

We had already learned this lesson. The move toward dedicated workers and locks helped, but BullMQ gives us a cleaner model. Multiple workers can listen to the same queue, and jobs are claimed by workers in a coordinated way through Redis.

This means we can scale processing capacity more naturally. Add more workers when there is more work. Keep scheduling and execution separated. Let the queue absorb bursts instead of forcing every task to run immediately.

Cron Helped Us Start. BullMQ Helps Us Grow.

The story is not “cron was wrong.”

Cron was right for the first version. It gave us a simple way to ship scheduled work quickly. It helped us learn which jobs mattered, how often they ran, and where the pain points were.

BullMQ is right for the next version because the problem changed.

We now need retries, delayed jobs, recurring schedules, concurrency, clearer history, safer scaling, and better control over different kinds of work. BullMQ gives us those pieces in a focused, practical way.

It is not a heavy platform bet. It is a natural step from “run this on a schedule” to “manage this work reliably.”

Every architecture introduces trade-offs. For us, the question wasn't whether BullMQ was more complex than cron, it clearly was. The question was whether that complexity was solving problems we were already facing. By the time we made the switch, the answer was yes.

Tags

Try Statement Today

Get early updates, and product notes.

Get early access