Ver en Español
Learning BullJS
Jun 24, 2026
Updated: Jun 25, 2026

Learning BullJS

It's quite common in our life as developers to run into a project with very specific requirements around functions and how they run. For example, JavaScript functions that need to run a set number of times, that should retry if they fail, or that need prioritization to know which one to run first, among many other things. This is where queue management systems for Node.js start to take center stage. In this article we'll focus on one in particular: BullJS.

BullJS is a library supported by Node.js designed for these scenarios, and it also persists information in a Redis database. It doesn't stop there: it also offers task parallelism, notifications between producers and consumers, and progress tracking for tasks, among others.

The project describes itself as: "The fastest, most reliable, Redis based queue for Node.js, carefully written for rock solid stability and atomicity."

Important note (BullMQ vs. classic `bull`): This post covers the classic bull package (npm install bull, import Queue from "bull"). Today the maintainers (Taskforce.sh) treat classic bull as maintenance only and recommend BullMQ for all new projects. The concepts you'll see here (queues, jobs, delay/attempts options, processing, events) are still valid and carry over almost directly to BullMQ, but some APIs are renamed. If you're starting a project from scratch, install bullmq and read its migration guide. Throughout this post I flag the relevant differences.

Here you'll find the links to the official documentation:

  • Guide, your entry point to start building (BullMQ documentation).
  • Repository, official BullMQ repository.
  • Classic Bull, repository of the original bull package this tutorial is based on.

Getting started

To install BullJS you first need Node.js installed, and then run the following command:

npm install bull

As we mentioned earlier, Bull needs a Redis database, since that's where the "jobs" and messages are stored and managed. If you have Docker installed on your machine, you can run:

docker run --name my-redis-container -p 6379:6379 -d redis

This will start a local Redis database running on 127.0.0.1:6379.

An introductory example

Imagine we want to implement a "job" (in BullJS, tasks are called "jobs") that does the following: "7 days after someone subscribes to our newsletter, we want to send them an email containing a link to rate their subscription experience on our website."

BullJS has two main elements that define the whole ecosystem you work with: queues, or "Queues", and tasks, or "Jobs". First, let's look at queues.

Queues in BullJS

A queue is a JavaScript object that can produce and consume jobs. In our example we'll call a queue newsLetterMail, but you can name it whatever you like. When we create an instance of a queue we must specify the host and port of our Redis database, since the default is 127.0.0.1:6379. Let's see how this would look:

// Imports the Queue class from the "bull" library
import Queue from "bull";

// Creates a new Queue instance called "newsLetterMailQueue" to handle sending newsletter emails
const newsLetterMailQueue = new Queue("newsLetterMail", {
  // Configures the connection to the Redis server, used to store and manage the tasks in the queue
  redis: {
    // Sets the IP address of the Redis server (here, the local IP address)
    host: "127.0.0.1",
    // Sets the port the Redis server listens on (the default port is 6379)
    port: 6379,
  },
});

Notice that we imported Bull with the alias Queue and created the queue by passing it two arguments: the name and an object with the Redis configuration. Now that we have a queue, let's move on to the Jobs.

Jobs in BullJS

With our Queue ready, let's create our first Job. For this we'll pass a data object containing the email address we want to send the email to, plus some options. In this example we want to process the job 7 days after it was created, and if the job fails, it will be retried three times.

// Creates an object called "data" containing information to send an email
const data = {
  // Sets the email address the newsletter will be sent to
  email: "foo@bar.com",
};

// Creates an object called "options" with configuration options for adding a task to the queue
const options = {
  // Sets a delay before the task runs, here 7 days (86400000 milliseconds per day)
  delay: 86400000 * 7,
  // Sets the maximum number of attempts to process the task if it fails
  attempts: 3,
};

// Adds a new task to the "newsLetterMailQueue" queue with the email information and the configured options
newsLetterMailQueue.add(data, options);

To add a job to a queue we use the add function, which lives on the JavaScript object returned when we create the queue. This makes BullJS add the job to the database with the options we specified.

Processing a Job

To process a Job we need to specify a function that can be called for each job in a queue, no matter how many there are. This function is called process and is part of the queue object we defined:

newsLetterMailQueue.process(async (job) => {
  await sendNewsLetterMailTo(job.data.email);
});

We extracted the email property from the Job via job.data and then called a function that sends the email. If this function fails because of some JavaScript error, BullJS will catch that error and retry it up to a maximum of 3 times, or as many times as we specified in the Job's options.

Completing a Job

Now let's imagine the execution has finished. How can we know this? Or better yet, how do we know if something failed? Every time a Job's process finishes, we need to resolve a promise or run a callback. Let's look at these two options:

newsLetterMailQueue.process(async (job, done) => {
  await sendNewsLetterMailTo(job.data.email);
  done(null, { message: "Email sent" });
});

In the example above, the done callback receives two parameters: error and result. Since everything went well, we passed null as the error and, as the result, an object with the success message.

newsLetterMailQueue.process(async (job) => {
  await sendNewsLetterMailTo(job.data.email);
  return Promise.resolve({ message: "Email sent" });
});

Now, using promises, we have the option to return a resolved or a rejected promise. In this case, since we want to complete the job successfully and without errors, we return the result inside the resolve of our promise.

It's also possible to report a Job's progress via job.progress, since if we have another entity listening for jobs in a queue, it will be an excellent notification signal between the two systems.

For example, we could update the Job's progress like this:

newsLetterMailQueue.process(async (job) => {
  job.progress(50); // Updates progress to 50%
  await sendNewsLetterMailTo(job.data.email);
  job.progress(100); // Updates progress to 100%
  return Promise.resolve({ message: "Email sent" });
});

This lets us keep track of each Job's progress and communicate it between the different parts of the system that care about the Jobs' state.

If you use BullMQ: The method to report progress was renamed. Instead of job.progress(50) you should use await job.updateProgress(50). The rest of the concept is identical.

Handling errors in Jobs with BullJS

Bull is a very useful library for handling queues and jobs in Node.js. In this section we'll see how to handle errors, manage job concurrency, and listen to the state of jobs using BullJS.

Error handling in a Job

When we work with BullJS it's important to know that try catch blocks do not work inside the .process() function. Therefore, we must handle errors using the done object or by returning a rejected promise. Here's an example:

newsLetterMailQueue.process(async (job, done) => {
  await sendNewsLetterMailTo(job.data.email);
  done(new Error("Something went very wrong"));
});

newsLetterMailQueue.process(async (job) => {
  await sendNewsLetterMailTo(job.data.email);
  return Promise.reject(new Error("Something went very wrong"));
});

Notice that we reject with new Error(...) rather than a plain object. Bull and Node expect Error instances to produce proper stack traces and a useful payload on the failed event, so we keep the same style we used with done(new Error(...)).

Job concurrency

BullJS lets us handle job concurrency by taking advantage of our computer's processors. To do this we put the process function in a separate file and then tell the queue, through the first argument of process, how many jobs we want to run in parallel. First we define the processor in its own file:

// path/to/funcion/file.js
const processJob = async (job) => {
  // Do something
  await sendNewsLetterMailTo(job.data.email);
};

module.exports = processJob;

Then we register that file as the processor, passing the maximum concurrency as the first argument (here, 5 jobs in parallel):

// Processes up to 5 jobs concurrently using the external processor file
newsLetterMailQueue.process(5, "/path/to/funcion/file.js");

By passing a path to a file, Bull runs each job in a sandboxed processor, in its own Node.js process. This way BullJS can run several jobs at the same time without blocking one another.

If you only need concurrency within the same process, you can also pass the number as the first argument to an inline processor: newsLetterMailQueue.process(5, async (job) => {... }).

Listening to the state of Jobs

One of the most interesting features of BullJS is that we can listen to the state and result of a job in the same application or in an external application. To do this we must create a queue with the same name and listen for an event that tells us when a job has completed.

import Queue from "bull";

const newsLetterMailQueue = new Queue("newsLetterMail", {
  redis: {
    host: "127.0.0.1",
    port: 6379,
  },
});

newsLetterMailQueue.on("global:completed", async (jobId, result) => {
  // In result we get the result that is sent and, in addition, a unique identifier for the Job
  await sendSMS();
});

There are two important things to note here:

  1. The server listening for the messages must have BullJS installed.
  2. It must point to exactly the same Redis the other server is using.

The global:completed event is the one used across servers, that is, for servers external to the one processing the job. But if what you want is to do it all together in the same server or project, you simply listen for the completed event.

Besides the completed event there's a huge list of events you can listen to. Here they are:

queue
  .on("error", function (error) {
    // An error occured.
  })
  .on("waiting", function (jobId) {
    // A Job is waiting to be processed as soon as a worker is idling.
  })
  .on("active", function (job, jobPromise) {
    // A job has started. You can use `jobPromise.cancel()` to abort it.
  })
  .on("stalled", function (job) {
    // A job has been marked as stalled. This is useful for debugging job
    // workers that crash or pause the event loop.
  })
  .on("progress", function (job, progress) {
    // A job's progress was updated!
  })
  .on("completed", function (job, result) {
    // A job successfully completed with a `result`.
  })
  .on("failed", function (job, err) {
    // A job failed with reason `err`!
  })
  .on("paused", function () {
    // The queue has been paused.
  })
  .on("resumed", function (job) {
    // The queue has been resumed.
  })
  .on("cleaned", function (jobs, type) {
    // Old jobs have been cleaned from the queue. `jobs` is an array of cleaned
    // jobs, and `type` is the type of jobs cleaned.
  })
  .on("drained", function () {
    // Emitted every time the queue has processed all the waiting jobs (even if
    // there can be some delayed jobs not yet processed).
  })
  .on("removed", function (job) {
    // A job successfully removed.
  });

You can add the global: prefix to any of these events to use them across projects.

Conclusions

BullJS is a powerful, optimized, and easy to use library that lets you control your tasks, concurrency, and notifications across projects. We encourage you to try BullJS and explore all of its examples. And remember: for new projects in 2026, look at BullMQ first, which is the recommended successor.

Proposed exercises

  1. Implement a queue system to process images using BullJS.
  2. Add error handling and concurrency to your queue system implementation.
  3. Create a separate application that listens and responds to completed job events, showing real time notifications to users.

3-point summary

  1. BullJS lets us handle errors in jobs through the done object or by returning a promise rejected with new Error(...).
  2. We can manage job concurrency by putting the process function in a separate file and passing the maximum concurrency as the first argument of process.
  3. BullJS lets us listen and respond to job events in the same application or in external applications, making communication between servers easy with the global: prefix.

That's all, I hope this article was useful to you and that you can apply it to a project you have in mind. If you have any questions or comments, don't hesitate to leave them in the comments section below. And if you liked it, share this article with your friends on social media using the links below. See you next time!

Sebastian Gomez

Sebastian Gomez

Creador de contenido principalmente acerca de tecnología.

Leave a Reply

0 Comments

Advertisements

Related Posts

Categorias