Fair streams, one queue.
Put each tenant, webhook destination or customer in a group. bunqueue keeps FIFO ties inside that stream, rotates fairly across streams and enforces backpressure at the broker.
Add grouped jobs
Section titled “Add grouped jobs”The Bun client supports the group API in embedded and TCP mode:
import { Queue, Worker } from 'bunqueue/client';
const queue = new Queue('webhooks');
await queue.add('deliver', { event: 'created' }, { group: { id: 'tenant-a' } });await queue.add('deliver', { event: 'updated' }, { group: { id: 'tenant-a' } });await queue.add('deliver', { event: 'created' }, { group: { id: 'tenant-b' } });When both groups are ready, claims rotate tenant-a, tenant-b,
tenant-a. Equal-priority jobs inside each group stay FIFO. Ready jobs without
a group are served before grouped jobs.
Group IDs may be non-empty strings or safe integers and may contain at most 256 characters. NUL characters are rejected. Numeric IDs are normalized to strings; objects and fractional or unsafe numeric IDs are not coerced.
Use priority for ordered work inside one group and maxSize for atomic
producer backpressure. Priority 0 is highest; among positive priorities, a
lower number runs first. Values must be integers from 0 through 2,097,151:
await queue.add('deliver', payload, { group: { id: 'tenant-a', priority: 1, maxSize: 10_000 },});maxSize counts pending jobs in waiting, prioritized, and delayed states; an
active job no longer consumes that pending-cap slot. The broker performs the
count and insert as one admission decision. A full group rejects a single add
or the complete atomic FlowProducer graph without leaving partial jobs, and
PostgreSQL addBulk is one transaction. The embedded (memory/SQLite) addBulk
instead follows the accepted-prefix contract: jobs before the one that hits the
cap are admitted and persisted, then the rejection is thrown.
FIFO does not mean concurrency one
Section titled “FIFO does not mean concurrency one”FIFO controls which job is claimed first. By default, multiple jobs from the same group may execute at once. Set the broker-side group concurrency when you need serial or bounded work per tenant:
const worker = new Worker('webhooks', deliverWebhook, { concurrency: 50, group: { concurrency: 2, // at most two active jobs per group },});Use concurrency: 1 for strictly serial execution per group. The ordinary
Worker concurrency still caps total parallel work in this process.
Per-group rate limits
Section titled “Per-group rate limits”group.limit gives every group its own fixed window, enforced when the broker
claims a job:
const worker = new Worker('webhooks', deliverWebhook, { concurrency: 100, group: { limit: { max: 20, duration: 1000 }, // 20 starts/s for each group },});The budget is shared by all Workers. In PostgreSQL multi-broker mode it is stored and consumed transactionally in PostgreSQL, so several brokers still share one exact budget per group.
All Workers consuming the queue should use the same group defaults. A Worker
that omits group.limit has no group rate limiting; one that omits
group.concurrency has unlimited group concurrency.
Override one noisy group
Section titled “Override one noisy group”Local overrides let one tenant use a different policy without changing the Worker defaults:
await queue.setGroupRateLimit('tenant-a', 5, 1000);await queue.setGroupConcurrency('tenant-a', 1);
console.log(await queue.getGroupRateLimit('tenant-a'));// { max: 5, duration: 1000 }
console.log(await queue.getGroupConcurrency('tenant-a'));// 1An override is effective only when the Worker supplied the corresponding default. This matches BullMQ Pro’s local group override behavior: a stored rate override does not turn rate limiting on by itself, and a stored concurrency override does not turn group concurrency on by itself.
Remove overrides with:
await queue.removeGroupRateLimit('tenant-a');await queue.removeGroupConcurrency('tenant-a');Pause and resume one tenant without pausing the queue:
await queue.pauseGroup('tenant-a');await queue.isGroupPaused('tenant-a'); // trueawait queue.resumeGroup('tenant-a');Inside a processor, apply an immediate cooldown and return the current delivery
to waiting with await worker.rateLimitGroup(job, 30_000). This manual deadline
works even when the Worker has no group.limit default. The stored
setGroupRateLimit override above is different: it customizes a configured
fixed-window default and has no effect when all Workers omit that default.
The broker installs the manual deadline before moving the active delivery back
to waiting. If the lease is stale and that move rejects, the Promise rejects but
the group cooldown remains active.
Depth and autoscaling signals
Section titled “Depth and autoscaling signals”const tenantDepth = await queue.getGroupJobsCount('tenant-a');const groupedDepth = await queue.getGroupsJobsCount();const tenantActive = await queue.getGroupActiveCount('tenant-a');const retryIn = await queue.getGroupRateLimitTtl('tenant-a', 5);const jobs = await queue.getGroupJobs('tenant-a', 0, 99);const priorities = await queue.getCountsPerPriorityForGroup('tenant-a');getGroupJobsCount and getGroupsJobsCount count queued grouped jobs in the
waiting, prioritized and delayed states. They exclude active jobs; add
getGroupActiveCount when an autoscaler needs both backlog and in-flight work.
getGroupRateLimitTtl returns the remaining window in milliseconds, 0 when
the optional maxJobs threshold still has room, and -2 when no live window
exists.
These are server-authoritative reads. Embedded mode maintains O(1) mirrored depth counters. PostgreSQL queries the durable job/group state, so a read from one broker includes work admitted or claimed through another.
Persistence and cleanup
Section titled “Persistence and cleanup”SQLite persists group-specific rate/concurrency configuration across broker
restarts, including pause state; its current fixed-window and manual deadline
reset with the process, like the existing SQLite queue-level limiter.
PostgreSQL persists configuration, pause/manual deadlines, live fixed-window
accounting, immutable grouped admission order and round-robin position.
Inactive PostgreSQL scheduler rows are reclaimed only after their jobs,
overrides and live rate window are gone. queue.obliterateAsync() removes the
jobs and all group configuration for that queue.
Where to go next
Section titled “Where to go next”| Rate Limits & Concurrency | Queue-wide limits and how they compose with group limits |
| Worker Concurrency | Total Worker parallelism and batch pulling |
| SQLite / PostgreSQL | Share group order and budgets across brokers |
| Job Options | All per-job options, including group |