·QueueHub Team·13 min read

BullMQ Priority Queues and Groups: Ordering Jobs by Importance

bullmqpriority-queuesbullmq-projob-priorityredisgroupsqueue-management

Not all jobs are created equal. In a typical production system, a password-reset email should be processed before a weekly analytics digest, and an urgent order cancellation matters far more than a background cache-warming task. Yet the default behavior of most job queues — BullMQ included — is first-in, first-out (FIFO) processing, which treats every job the same regardless of importance.

BullMQ gives you the tools to break out of FIFO. With its built-in priority system (free and open-source), you can assign importance levels to individual jobs. And with BullMQ Pro Groups, you get multi-tenant isolation, per-group rate limiting, and intra-group priority ordering — critical capabilities for any SaaS backend serving hundreds of customers from a single queue.

In this post, we'll cover:

  • How BullMQ's priority system works, including the Redis internals
  • BullMQ Pro Groups — fair scheduling across tenants with per-group priorities
  • When to use priorities vs. separate queues (and how Groups combines the best of both)
  • How to monitor priority and group state with QueueHub

This guide is written for Node.js/TypeScript developers running BullMQ in production — or evaluating it for a multi-tenant system.


How Priority Works in BullMQ (OSS)

BullMQ's priority system is available in the open-source version. Every job can carry a priority integer at submission time. Lower numbers mean higher priority — the same semantics as Unix nice values.

The priority Option

Passing a priority to queue.add() is straightforward:

import { Queue } from "bullmq";

const queue = new Queue("orders");

// These will be processed in order: brown, then blue, then pink
await queue.add("paint", { color: "brown" }, { priority: 5 });
await queue.add("paint", { color: "blue" }, { priority: 7 });
await queue.add("paint", { color: "pink" }, { priority: 10 });

// This job has no priority — it gets processed before all of the above
await queue.add("paint", { color: "gold" }, {});

A few important details about this system:

  • The valid priority range is 1 to 2,097,152 (2²¹ − 1)
  • Jobs without a priority field receive the highest precedence — they are processed before any job that has a priority set
  • Jobs sharing the same priority value maintain FIFO ordering among themselves

Changing Priority After Insertion

Sometimes you need to escalate a job after it's already in the queue. BullMQ's changePriority method makes this possible:

import { Job } from "bullmq";

// Create a job with a normal priority
const job = await Job.create(queue, "send-email", { to: "user@example.com" }, { priority: 10 });

// Escalate it — this email is now critical
await job.changePriority({ priority: 1 });

// You can also switch to LIFO within its priority tier
await job.changePriority({ lifo: true });

Querying Priority State

Knowing how many jobs are waiting at each priority level helps you understand queue health:

// List all prioritized jobs (paginate to avoid O(n) scans!)
const jobs = await queue.getPrioritized(0, 100);

// Get counts broken down by priority level
const counts = await queue.getCountsPerPriority([1, 5, 10]);
// Returns something like: { "1": 3, "5": 12, "10": 8 }

Inside the Engine: Redis Sorted Sets with Priority Scores

Understanding what happens under the hood helps you make better decisions about priority usage in production.

The Old O(n) Approach (BullMQ < 4.0.0)

Before BullMQ 4.0.0, priority jobs were stored using a sorted set to determine the insertion position, but jobs were then appended to a separate wait list. This meant every priority insertion required an O(n) scan — fine for small queues, but a bottleneck at scale.

The Current O(log n) Approach (BullMQ ≥ 4.0.0)

Starting with version 4.0.0, BullMQ switched to a single prioritized Redis sorted set — no separate waitlist. The scoring formula is:

score = (priority << 32) + counter

Here's how it works:

  • The priority value is bit-shifted 32 bits to the left, occupying the high-order bits of the score
  • An auto-incrementing counter occupies the low 32 bits, ensuring that jobs within the same priority tier are retrieved in FIFO order
  • Redis ZADD runs in O(log n), making job insertion fast even with millions of waiting jobs

Key engineering details:

  • Priority occupies 21 bits (hence the 2,097,152 limit)
  • The counter occupies 32 bits, allowing approximately 4 billion priority jobs before overflow
  • The counter resets when the queue is fully drained
  • This was a breaking change from BullMQ 3.x to 4.x — if you're migrating, call queue.removeDeprecatedPriorityKey() to clean up old keys

Why This Matters in Production

The O(log n) design gives you predictable performance even as queues grow. Every insertion takes roughly the same amount of time regardless of queue depth. There's no O(n) scan on new job submissions, which keeps producer latency low under high throughput.


BullMQ Pro Groups: Multi-Tenant Queue Isolation

BullMQ Pro extends the open-source queue with Groups — a feature designed for multi-tenant workloads where fairness across customers is essential.

The Problem Groups Solve

Imagine one queue serving 1,000 users. Without groups, if User A floods the queue with 10,000 jobs, every other user has to wait until User A's backlog drains. This "noisy neighbor" problem is the single biggest challenge with shared queues.

Groups solve this by assigning each job to a named group. Workers consume jobs in round-robin fashion — one job from User A, then one from User B, then one from User C — ensuring no single tenant can monopolize the queue.

Adding Jobs to Groups

import { QueuePro } from "@taskforcesh/bullmq-pro";

const queue = new QueuePro("transcodes");

// Each user gets their own group
await queue.add("transcode", { userId: 1, video: "intro.mp4" }, {
  group: { id: "user-1" },
});

await queue.add("transcode", { userId: 2, video: "demo.mp4" }, {
  group: { id: "user-2" },
});

await queue.add("transcode", { userId: 3, video: "tutorial.mp4" }, {
  group: { id: "user-3" },
});

Worker (No Special Handling Needed)

The worker processes grouped jobs transparently — BullMQ Pro handles the round-robin scheduling:

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

const worker = new WorkerPro("transcodes", async (job) => {
  // Jobs from user-1, user-2, user-3 are processed fairly
  console.log(`Processing group: ${job.opts.group.id}`);
});

Groups + Priorities — Intra-Group Ordering

Priorities work inside a group too, so a tenant can have their own ordering:

await queue.add("transcode", { userId: 1, video: "urgent.mp4" }, {
  group: { id: "user-1", priority: 1 },    // high priority within group
});

await queue.add("transcode", { userId: 1, video: "cleanup.mp4" }, {
  group: { id: "user-1", priority: 100 },   // low priority within group
});

Important distinction: When using intra-group priority, pass priority inside the group object (not at the job root level). The root-level priority controls ordering across groups, while the group-level priority controls ordering within a single group. Using both simultaneously is powerful but easy to misconfigure — test thoroughly.

Per-Group Rate Limiting

Groups also let you control how fast each tenant can be processed — critical for API rate-limit compliance:

const worker = new WorkerPro("myQueue", processFn, {
  group: {
    limit: {
      max: 100,        // 100 jobs per second per group
      duration: 1000,
    },
  },
  connection,
});

For dynamic rate limiting when an external API returns a 429:

const worker = new WorkerPro("transcodes", async (job) => {
  const groupId = job.opts.group.id;
  const [isLimited, duration] = await checkExternalApiQuota(groupId);

  if (isLimited) {
    await worker.rateLimitGroup(job, duration);
    throw Worker.RateLimitError();
  }
}, { connection });

You can also set per-group overrides for premium tenants:

// Premium users get 500/s instead of the default 100/s
await queue.setGroupRateLimit("premium-user", 500, 1000);

Group Observability APIs

BullMQ Pro provides dedicated getters for group-level monitoring:

// Total jobs across all groups
const total = await queue.getGroupsJobsCount(1000);

// Active jobs for a specific group
const active = await queue.getGroupActiveCount("user-1");

// Paginated jobs within a group
const jobs = await queue.getGroupJobs("user-1", 0, 100);

// Priority counts within a specific group
const priorityCounts = await queue.getCountsPerPriorityForGroup("user-1", [1, 5, 10]);

Real-World Use Cases

Multi-Tenant SaaS Platform

Challenge: A video transcoding service serves hundreds of customers. One customer uploading a 4K feature-length film should not delay another customer's 30-second clip.

Solution with Groups:

  • One BullMQ Pro queue for all transcodes
  • Map each customer ID to a group ID
  • Align per-group rate limits to customer tiers
  • Use intra-group priorities for customer-facing uploads vs. internal processing
Tier Group rate limit Max intra-group priority
Enterprise 500/s 1 (critical)
Pro 200/s 5
Starter 50/s 10
Free 10/s 20

Golden Signals vs. Background Cleanup

Challenge: The same queue handles both user-facing jobs (order confirmations, password resets) and internal maintenance tasks (log rotation, cache warming). Without priorities, a burst of cleanup jobs delays time-sensitive emails.

Solution (OSS only):

await queue.add("email", { type: "password-reset" }, { priority: 1 });
await queue.add("email", { type: "receipt" }, { priority: 5 });
await queue.add("maintenance", { type: "log-rotate" }, { priority: 100 });

Better solution (with Groups):

  • Group user-facing: priority 1, rate limit 200/s
  • Group maintenance: priority 100, rate limit 5/s

Priority Aging — Preventing Starvation

A common problem with priority systems: if high-priority jobs arrive continuously, low-priority jobs may never be processed. Priority aging boosts the priority of jobs that have been waiting too long:

// Every 60 seconds, boost jobs waiting more than 5 minutes
setInterval(async () => {
  const jobs = await queue.getPrioritized();
  for (const job of jobs) {
    const elapsed = Date.now() - job.timestamp;
    if (elapsed > 5 * 60 * 1000 && (job.opts.priority ?? 100) > 1) {
      await job.changePriority({ priority: (job.opts.priority ?? 100) - 1 });
    }
  }
}, 60_000);

This pattern ensures fairness over time — every job eventually gets promoted enough to be processed.


Priorities vs. Separate Queues: How to Decide

A question every BullMQ user faces: when should I use priorities within a single queue, and when should I create separate queues? Here's a decision framework:

Criteria Use priorities (one queue) Use separate queues
Strict ordering across urgency levels ✅ Yes ❌ Workers compete independently
Different processing requirements (concurrency, retries, backoff) ❌ Shared config ✅ Per-queue config
Monitoring simplicity ✅ One queue to watch ❌ N queues to monitor
Group isolation (multi-tenant) ✅ Groups (Pro) handle this ❌ Must manage N queues × N workers
Workers need different code paths ❌ Single processor must dispatch ✅ Separate worker files
You have only N priority tiers (e.g. low/med/high) ✅ Works well ✅ Also works, but more infra

Rule of thumb:

  • Same worker code, different urgency → use priorities
  • Different worker code, different SLA, different backends → use separate queues
  • Multi-tenant fairness → use BullMQ Pro Groups (combines the best of both worlds)

Common Pitfalls and Production Considerations

Counter Overflow

After approximately 4 billion priority jobs in a single queue, the 32-bit counter wraps around. This can temporarily break ordering within the same priority tier. Mitigation: Periodically drain and re-create the queue, or implement a manual counter reset.

Priority Range Creep

As teams grow, new developers may invent random priority numbers — 17, 18, 19 — making it hard to reason about relative importance. Mitigation: Define a constants enum:

export const Priority = {
  CRITICAL: 1,
  HIGH: 5,
  NORMAL: 10,
  LOW: 50,
  BACKGROUND: 100,
} as const;

Starvation of Low-Priority Jobs

If high-priority jobs arrive continuously, low-priority jobs may never run. Mitigation: Use priority aging (as shown above) or fall back to separate queues with dedicated workers.

O(n) Redis Operations

getPrioritized() without pagination can attempt to fetch millions of jobs from Redis, causing latency spikes. Mitigation: Always paginate — getPrioritized(0, 100) — and use getCountsPerPriority for aggregate data.

Groups + Root-Level Priority Confusion

  • Root priority on a grouped job orders jobs across groups
  • Group-level priority (inside the group object) orders jobs within a group
  • Using both simultaneously is powerful but easy to misconfigure — always double-check which field you're setting

Groups Are BullMQ Pro Only

BullMQ OSS does not support groups. The group option is silently ignored on OSS — always test with BullMQ Pro before deploying in production.


Monitoring Priorities & Groups with QueueHub

BullMQ is a library, not a platform — it doesn't ship with a UI. When you're running priorities and groups in production, you need visibility into:

  • How many jobs are waiting at each priority level?
  • Which groups are backed up or being rate-limited?
  • Can I change a job's priority directly from the UI?

QueueHub fills this observability gap with a dedicated dashboard for BullMQ:

  1. Priority distribution view — See the count of jobs per priority bracket in one glance, powered by getCountsPerPriority() under the hood
  2. Group-level dashboards — For BullMQ Pro users, QueueHub surfaces getGroupActiveCount, getGroupsJobsCount, and rate-limit TTL per group
  3. Live worker view — See which group a worker is currently processing; spot groups being starved before they become incidents
  4. Multi-backend support — Monitor queues across local Redis, TLS Redis, Valkey, AWS ElastiCache, and private Redis behind agent tunnels — all in one dashboard
  5. Alerts — Get notified when a priority tier's backlog crosses a threshold, or when a group enters rate-limit hold
  6. Job detail view — Inspect any job's opts.priority and opts.group directly, with the ability to changePriority from the UI

Conclusion

BullMQ's priority system gives you fine-grained control over job ordering using a fast, O(log n) Redis sorted set under the hood. It's free, it's built into the OSS version, and it's perfect for ordering jobs within a single queue.

When you need multi-tenant fairness, BullMQ Pro Groups add round-robin scheduling, per-group rate limits, and intra-group priorities — essential capabilities for any SaaS backend serving diverse customers from shared infrastructure.

And QueueHub gives you the observability layer that BullMQ doesn't ship, turning priority distributions, group backlogs, and rate-limit state into actionable dashboards.

Ready to take control of your job queue? Spin up BullMQ's priority system today — it's free. If you're running a multi-tenant system, evaluate BullMQ Pro Groups. And when you need visibility into it all, try QueueHub to see your priority and group data live.

Related Articles