BullMQ Priority Queues and Groups: Ordering Jobs by Importance
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
1to2,097,152(2²¹ − 1) - Jobs without a
priorityfield 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
priorityvalue is bit-shifted 32 bits to the left, occupying the high-order bits of the score - An auto-incrementing
counteroccupies 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
priorityon a grouped job orders jobs across groups - Group-level
priority(inside thegroupobject) 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:
- Priority distribution view — See the count of jobs per priority bracket in one glance, powered by
getCountsPerPriority()under the hood - Group-level dashboards — For BullMQ Pro users, QueueHub surfaces
getGroupActiveCount,getGroupsJobsCount, and rate-limit TTL per group - Live worker view — See which group a worker is currently processing; spot groups being starved before they become incidents
- Multi-backend support — Monitor queues across local Redis, TLS Redis, Valkey, AWS ElastiCache, and private Redis behind agent tunnels — all in one dashboard
- Alerts — Get notified when a priority tier's backlog crosses a threshold, or when a group enters rate-limit hold
- Job detail view — Inspect any job's
opts.priorityandopts.groupdirectly, with the ability tochangePriorityfrom 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
Testing and Development Workflows for BullMQ vs BeeQueue
A practical guide to testing job queue code with BullMQ and BeeQueue — test fixtures, producer and worker testing patterns, local development workflows, and CI integration.
BeeQueue Production Patterns and Redis Internals: Beyond the Basics
Go beyond basic BeeQueue setup. Master Redis key internals, Prometheus health checks, dead letter queues, graceful shutdown, batch processing, and benchmarking methodology.
Beyond Static Priority — Building a Dynamic Priority Escalation Engine with BullMQ
Learn how to build a dynamic priority escalation engine for BullMQ with three production-ready patterns: configurable escalation rules, multi-factor priority scoring, and priority-weighted worker pool routing with complete TypeScript code.