0x55aa
Back to Blog

🏭 Node.js Job Queues: Stop Making Your Users Stare at a Spinner

|
6 min read

🏭 Node.js Job Queues: Stop Making Your Users Stare at a Spinner

Picture this: a user clicks "Export Report" on your app. Your server dutifully fetches data, crunches numbers, formats a PDF, attaches it to an email, and sends it — all while the user stares at a loading spinner, silently questioning their life choices.

Twenty seconds later, the request either succeeds or times out. Either way, the user is not impressed.

This is the moment job queues save the day — and your users' sanity.

What's a Job Queue, Anyway?

Think of a job queue like a ticket system at a deli counter. The customer (your API) doesn't stand at the counter while their sandwich is being made. They get a ticket number, go sit down, and the deli worker (your background worker) processes the order when it's their turn.

The customer gets immediate feedback ("your order is #47"). The work still happens. Nobody is blocking the entrance.

In Node.js land, Bull (or its newer cousin BullMQ) is the go-to library for this, powered by Redis. It's battle-tested, feature-rich, and gives you retry logic, priority queues, and job scheduling out of the box.

Setting Up Your First Queue

Install the essentials:

npm install bullmq ioredis

Now let's build a queue for sending welcome emails — the classic "slow thing you shouldn't do inline":

// queues/emailQueue.js
import { Queue, Worker } from "bullmq";
import IORedis from "ioredis";
import { sendWelcomeEmail } from "../services/email.js";

const connection = new IORedis({ maxRetriesPerRequest: null });

// The Queue — where jobs are added
export const emailQueue = new Queue("email", { connection });

// The Worker — where jobs are processed
const worker = new Worker(
  "email",
  async (job) => {
    const { userId, email, name } = job.data;

    console.log(`Processing welcome email for ${email}`);
    await sendWelcomeEmail({ userId, email, name });

    return { sent: true, timestamp: Date.now() };
  },
  {
    connection,
    concurrency: 5, // process up to 5 jobs simultaneously
  }
);

worker.on("completed", (job, result) => {
  console.log(`Job ${job.id} completed:`, result);
});

worker.on("failed", (job, err) => {
  console.error(`Job ${job.id} failed:`, err.message);
});

Then in your Express route, instead of sending the email yourself, you just drop a job into the queue:

// routes/auth.js
import { emailQueue } from "../queues/emailQueue.js";

app.post("/register", async (req, res) => {
  const { email, name, password } = req.body;

  // Create the user in the database
  const user = await User.create({ email, name, password });

  // Add the email job to the queue — returns IMMEDIATELY
  await emailQueue.add(
    "welcome-email",
    { userId: user.id, email, name },
    {
      attempts: 3, // retry up to 3 times on failure
      backoff: { type: "exponential", delay: 2000 }, // wait longer between retries
      removeOnComplete: 100, // keep last 100 completed jobs
      removeOnFail: 50,
    }
  );

  // Respond instantly — no waiting for the email to send
  res.status(201).json({ message: "Account created! Check your email." });
});

Your API responds in milliseconds. The email goes out a second later. The user is happy. You are happy. Everyone wins.

The Real Power: Retry Logic

Here's where queues truly shine over raw setTimeout hacks. What happens when your email provider is temporarily down? With inline code, the user gets an error. With a queue, the job fails gracefully, waits (exponentially longer each time), and retries automatically.

BullMQ gives you fine-grained control:

  • attempts: 3 — try the job up to 3 times before marking it failed
  • backoff: exponential — wait 2s, then 4s, then 8s between retries
  • Priority queues — critical jobs (password reset emails) jump ahead of newsletter sends
  • Delayed jobs — "send this reminder email in 24 hours"
  • Recurring jobs — "run this cleanup task every night at 2am"

That last one deserves a special mention. Cron-style recurring jobs in your app without fighting cron syntax in your infrastructure? Yes please.

// Schedule a daily cleanup job
await cleanupQueue.add(
  "purge-expired-sessions",
  {},
  {
    repeat: { pattern: "0 2 * * *" }, // every night at 2 AM
  }
);

What Should Go in a Queue?

Not everything belongs in a queue (synchronous lookups like "fetch this user's profile" should stay inline), but here's a solid mental model:

Queue it if it's any of these:

  • Slow (>200ms) — image resizing, PDF generation, report exports
  • Unreliable — third-party API calls, webhook deliveries, SMS sending
  • Deferrable — welcome emails, notification digests, analytics events
  • Schedulable — recurring maintenance, reminders, cache warming

Don't queue it if:

  • The user needs the result to continue (e.g., a payment authorization response)
  • It's fast and in-process anyway
  • Eventual consistency would confuse the user (e.g., "I just updated my name, why does it still show the old one?")

Monitoring: The Part People Skip

Here's the dirty secret: most teams set up queues and then fly blind. Jobs fail silently, queues back up, and nobody notices until users start complaining.

Bull Board is a beautiful, free dashboard that plugs directly into your Express app:

npm install @bull-board/express @bull-board/api
import { createBullBoard } from "@bull-board/api";
import { BullMQAdapter } from "@bull-board/api/bullMQAdapter.js";
import { ExpressAdapter } from "@bull-board/express";

const serverAdapter = new ExpressAdapter();
createBullBoard({
  queues: [new BullMQAdapter(emailQueue)],
  serverAdapter,
});

serverAdapter.setBasePath("/admin/queues");
app.use("/admin/queues", serverAdapter.getRouter());

Now head to /admin/queues and you've got a live view of pending, active, completed, and failed jobs. You can even manually retry failed jobs from the UI. Chef's kiss.

The Takeaway

Job queues aren't a premature optimization — they're a fundamental tool for any backend that does more than serve static data. The moment you have slow work triggered by user actions, you need a queue.

The pattern is always the same: receive the request fast, acknowledge it immediately, do the work in the background. Your users get snappy responses. Your workers handle load gracefully. Your retries handle transient failures automatically.

Stop blocking your API on slow work. Give your users a ticket number and let the deli make the sandwich.


Ready to queue things up? Start with one slow operation in your app — probably that email send — and move it to a BullMQ queue this week. You'll immediately feel the difference, and you'll wonder how you ever lived without it.

Got questions about scaling workers, handling job priorities, or dealing with Redis in production? Drop a comment below — let's talk queues.

Thanks for reading!

Back to all posts