# Job Groups: FIFO, Fairness and Backpressure

Partition one bunqueue queue by tenant or webhook destination with round-robin FIFO scheduling, per-group depth, rate limits and concurrency.

Canonical: https://bunqueue.dev/guide/queue/job-groups/

---

<div class="bq-wrap bq-hero">
  <span class="bq-eyebrow">guide · queue</span>
  <h1 class="bq-hero-h1 bq-bench-h1">Fair streams, <em>one queue.</em></h1>
  <p class="bq-hero-sub">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.</p>
</div>

:::note[Job groups, not QueueGroup]
This page covers jobs in one queue that use `group: { id }`. The separate
[Queue Groups](/guide/queue-group/) helper applies one operation to several
different queues.
:::

## Add grouped jobs

Both TypeScript packages support the complete group API. Use
`bunqueue/client` with Bun, or import the same classes from `bunqueue-client`
on Node.js and Deno. The following examples use TCP; embedded mode requires Bun:

```typescript
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`:

```typescript
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. A memory/SQLite broker's `addBulk`,
whether called through TCP or embedded mode, 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

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:

```typescript
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

`group.limit` gives every group its own fixed window, enforced when the broker
claims a job:

```typescript
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

Local overrides let one tenant use a different policy without changing the
Worker defaults:

```typescript
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'));
// 1
```

An 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:

```typescript
await queue.removeGroupRateLimit('tenant-a');
await queue.removeGroupConcurrency('tenant-a');
```

Pause and resume one tenant without pausing the queue:

```typescript
await queue.pauseGroup('tenant-a');
await queue.isGroupPaused('tenant-a'); // true
await 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

```typescript
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

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

| Guide | What it covers |
| ------------------------------------------------- | -------------------------------------------------------- |
| [Rate Limits & Concurrency](/guide/queue/limits/) | Queue-wide limits and how they compose with group limits |
| [Worker Concurrency](/guide/worker/concurrency/)  | Total Worker parallelism and batch pulling               |
| [SQLite / PostgreSQL](/guide/databases/)          | Share group order and budgets across brokers             |
| [Job Options](/guide/queue/options/)              | All per-job options, including `group`                   |