Tied to mission: real queues aren't FIFO-only. Support tickets have SLAs; third-party APIs return 429. Builds on Lesson 1 and Lesson 2.
Every job can carry a priority. BullMQ pulls higher-priority jobs before lower ones instead of strict FIFO.
await queue.add('wall', { color: 'pink' }, { priority: 10 });
await queue.add('wall', { color: 'brown' }, { priority: 5 });
await queue.add('wall', { color: 'blue' }, { priority: 7 });
// processed order: brown (5), blue (7), pink (10)
| Rule | Detail |
|---|---|
| Lower number = higher priority | Counter-intuitive. 1 beats 10. Range: 1 to 2 097 152. |
| No priority = highest | Jobs with no priority jump ahead of all prioritized jobs. Don't mix thoughtlessly. |
| Ties are FIFO | Same priority value → first-in-first-out within that tier. |
| Adding is O(log n) | Prioritized inserts are slower than normal ones (sorted set). Don't set priority on every job — only the ones that matter. |
const job = await queue.add('wall', { color: 'red' }, { priority: 9 });
await job.changePriority({ priority: 1 }); // bump to front
const counts = await queue.getCountsPerPriority(0, 1, 5, 10);
// { '0': 140, '1': 3, '5': 12, '10': 8 }
0 = jobs with no priority assigned.
active is never preempted by a higher-priority arrival. To cut something off, you'd pause the queue and re-add — don't reach for that lightly.
Rate limiting protects the thing your jobs call. Three layers in current BullMQ. Learn all three — they answer different questions.
new Worker('email', processor, {
connection,
limiter: { max: 10, duration: 1000 }, // 10 jobs / 1000ms
});
| Property | Meaning |
|---|---|
max | Max jobs to process in the window. |
duration | The window length, in ms. |
{max:10, duration:1000} → still 10 jobs/sec total, not 100. The limit is shared, not per-worker.
Jobs that would breach the limit stay in waiting — they are not failed, not lost.
Set from the queue side, independent of any worker's config. Useful when multiple worker pools or producers must respect one ceiling.
await queue.setGlobalRateLimit(100, 60_000); // 100 jobs/min, queue-wide
const { max, duration } = await queue.getGlobalRateLimit();
const ttl = await queue.getRateLimitTtl(); // >0 means currently throttled
await queue.removeGlobalRateLimit();
Static limits are guesses. When the downstream API itself says "slow down" (a 429 Too Many Requests with a Retry-After), act on it at runtime:
const worker = new Worker('email', async (job) => {
const [ok, retryAfterMs] = await callEmailApi(job.data);
if (!ok) {
await worker.rateLimit(retryAfterMs); // hold this queue for that long
throw Worker.RateLimitError(); // MUST throw this, not a normal error
}
}, {
connection,
limiter: { max: 5, duration: 1000 },
});
worker.rateLimit(ms) tells BullMQ "hold the queue"; throw Worker.RateLimitError() puts this job back to waiting without consuming a retry attempt. If you threw a normal Error instead, BullMQ would treat it as a failure (Lesson 2) and burn through attempts — exactly wrong.
Inspect/clear manual limits:
const ttl = await queue.getRateLimitTtl(); // ms remaining on a manual hold; 0 = free
await queue.removeRateLimitKey(); // force-clear a manual hold
limiter: { groupKey: 'customerId' } to rate-limit per tenant. This was removed in BullMQ 3.0. The RateLimiterOptions type now contains only { max, duration } — verified against the current source. Some docs pages still show groupKey in examples; they are stale. If you need per-tenant throttling today, use one queue per tenant, or a manual worker.rateLimit() keyed on the tenant inside your processor.
import { Queue, Worker } from 'bullmq';
import IORedis from 'ioredis';
const connection = new IORedis({ maxRetriesPerRequest: null });
type Mail = { to: string; tenant: string; vip: boolean };
const email = new Queue<Mail>('email', { connection });
// VIPs jump the queue
await email.add('send', { to: '[email protected]', tenant: 'acme', vip: true }, { priority: 1 });
await email.add('send', { to: '[email protected]', tenant: 'acme', vip: false });
new Worker<Mail>('email', async (job) => {
const [ok, retryAfter] = await provider.send(job.data);
if (!ok) {
await worker.rateLimit(retryAfter ?? 5000);
throw Worker.RateLimitError();
}
}, {
connection,
concurrency: 5,
limiter: { max: 20, duration: 1000 }, // ≤ 20 emails/sec globally
});
priority) and protect the fragile dependency from your own traffic (rate limit — static, global, or dynamic on 429). Together with retries and scheduling, this covers the mechanics of nearly every production queue.
priority: lower = higher. No priority = highest of all. Ties = FIFO. Insert is O(log n).{max, duration} is global across workers; throttled jobs stay waiting.setGlobalRateLimit coexists with the worker limiter (stricter wins).worker.rateLimit(ms) + throw Worker.RateLimitError() for real 429s — the throw is mandatory.groupKey is gone since BullMQ 3.0; use per-tenant queues or manual rate-limiting.1. Priority 5 vs priority 10 — which runs first?
2. A job with NO priority gets what treatment?
3. What is the cost of adding a prioritized job?
4. Does priority preempt a job already active?
5. 10 workers, each {max:10,duration:1000}. Total rate?
6. Where does a rate-limited job sit?
7. After worker.rateLimit(ms), what must you throw?
8. What does Worker.RateLimitError() prevent?
9. Is groupKey supported in current BullMQ?
10. Worker limiter vs global queue limit?
Or say "next" for Lesson 5: flows (parent/child job dependencies).