# Prioritize Work with Queues

Set up a critical lane and a bulk lane with named queues, choose strict versus weighted dispatch, isolate slow work in its own Worker Group, and cap a queue cluster-wide.

BackWave has no per-job priority number. Priority is expressed structurally: you route jobs onto named Queues and tell each Worker Group how to divide its attention across them. This guide walks the common shape end to end — a latency-sensitive lane that must never wait behind bulk work — and shows when to reach for a Dispatch Policy, when to split off a second Worker Group, and when to cap a queue with a cluster-wide Concurrency Limit.

If you have not read [Queues](/docs/core-concepts/queues) and [Worker Groups & Dispatch](/docs/core-concepts/worker-groups) yet, skim them first. They define the model this guide leans on: Queue, Worker Group, Pump, Dispatch Policy, and Concurrency Limit. The running example throughout is a `critical` lane (password-reset emails, low volume, latency-sensitive) competing with a `bulk` lane (search-index rebuilds, slow, high volume).

## Priority is routing, not a number

Every job belongs to exactly one Queue. You declare it on the `[Job]` attribute through the `Queue` property, and unless an enqueue call says otherwise, every job of that type lands there. When `Queue` is omitted it defaults to `"default"`.

```csharp title="Jobs.cs"
[Job("password-reset", Queue = "critical")]
public Task SendPasswordReset(Guid userId, CancellationToken cancellationToken)
    => /* send the email */;
```

The declared Queue is a default, not a fixture. Any single enqueue can override it by passing `queue`, which routes that one job onto a different lane while leaving the type's default untouched.

```csharp title="Program.cs"
// normally "critical" per the attribute; this one call diverts to "bulk"
await client.EnqueueAsync(new SendPasswordReset(userId), dueTime: DateTimeOffset.UtcNow, queue: "bulk");
```

What you will not find is a priority field on the job. There is no number to bump, no `HighPriority = true`. Priority is not a property of the work; it lives in the consumer's Dispatch Policy — the rule a Worker Group follows when it decides which Queue to pull from next. Two jobs are ordered relative to each other only because they sit on different Queues and a policy ranks those Queues. This is deliberate: it keeps ordering a property of your topology, which you can reason about and change in one place, rather than a per-message attribute scattered across every call site.

If you are arriving from Hangfire, the shift is small. Hangfire queues are also drained in an order the server defines, so the mental model — name your lanes, then tell the consumer how to walk them — already fits. What changes is that in BackWave the walking rule is an explicit, first-class object you construct, described next.

## Strict priority with `DispatchPolicy.Strict`

A `DispatchPolicy.Strict` (from the `BackWave.Core` namespace) lists Queues in priority order. The Worker Group always tries an earlier Queue before a later one, and reaches a later Queue only when every earlier Queue has no due work. The constructor takes the names either as a list or as loose arguments, so the common case reads naturally.

```csharp title="Program.cs"
Policy = new DispatchPolicy.Strict("critical", "bulk"),
```

With this policy the group empties `critical` completely before it touches `bulk`, on every pass. A password reset enqueued while a thousand index-rebuild jobs are waiting still runs next.

Be clear-eyed about the trade. Strict priority accepts tail starvation: if `critical` is continuously busy, `bulk` can wait indefinitely, and that is by design, not a bug to tune around. It is the right choice precisely when the high-priority lane is low-volume by construction — password resets arrive in a trickle, so `bulk` gets the vast majority of the group's time in practice and only ever yields during the brief moments a reset needs to jump the line. When the head lane can realistically saturate, you want fair sharing instead.

## Fair sharing with `DispatchPolicy.Weighted`

A `DispatchPolicy.Weighted` divides attention across Queues in proportion to integer weights rather than draining strictly in order. It takes a list of `(queue, weight)` pairs — there is no loose-argument form, so pass the tuples as a collection.

```csharp title="Program.cs"
Policy = new DispatchPolicy.Weighted([("high", 6), ("low", 1)]),
```

The selection is a smooth weighted round-robin: a 6:1 split is honored exactly and deterministically, interleaving turns evenly rather than running six `high` jobs in a random clump and then one `low`. There is no randomness to average out over time; the ratio holds turn by turn.

Weights are shares, not reservations, and the policy is work-conserving: a worker never idles while any served Queue has due work. If `low` is momentarily empty, its turn flows to `high` instead of being held open — you get the full ratio only while both lanes have work, and all available capacity otherwise. Weights are positive integers; the larger relative to its peers, the more turns a Queue earns.

Choosing between the two policies comes down to whether starving the tail is acceptable:

| | `DispatchPolicy.Strict` | `DispatchPolicy.Weighted` |
|---|---|---|
| Order | Earlier Queues drained fully first | Interleaved by weight ratio |
| Tail behavior | Can starve indefinitely | Always gets its share when it has work |
| Idle-lane capacity | Flows to the next lane with work | Flows to the busy lanes (work-conserving) |
| Use when | The high lane is low-volume by construction | Every lane must make steady progress |
| Configured with | `Strict("critical", "bulk")` | `Weighted([("high", 6), ("low", 1)])` |

Both policies live in `BackWave.Core`; see [Dispatch Policies](/docs/reference/dispatch-policies) for the full reference.

## Isolate slow work in its own Worker Group

A Dispatch Policy decides which Queue a group pulls from next, but every job the group claims still runs in the same shared pool of execution slots. When `bulk` jobs are long-running, that is the limit dispatch tuning cannot fix: a burst of slow index rebuilds can occupy every slot for minutes, and a password reset the policy would happily prioritize has nowhere to run until a slot frees. No weighting changes that, because the contention is over execution capacity, not fetch order.

The fix is to give the slow work its own Worker Group. A single process can host several groups, each with its own Dispatch Policy and its own pool, and each claiming independently. Bind one group to `bulk` alone and cap its resources; leave the other to serve `critical` on its own dial.

```csharp title="Program.cs"
using BackWave.Core;
using BackWave.Hosting;

backwave.AddWorkerGroup(new WorkerGroupOptions
{
    Name = "critical",
    Policy = new DispatchPolicy.Strict("critical"),
});

backwave.AddWorkerGroup(new WorkerGroupOptions
{
    Name = "bulk",
    Policy = new DispatchPolicy.Strict("bulk"),
    PoolSize = 4,
});
```

Now a flood of `bulk` work can occupy at most its own group's slots; the `critical` group's capacity is untouched, and resets keep running. Two knobs govern how much a group can run at once:

| Option | Meaning | Default |
|---|---|---|
| `PoolSize` | Execution slots per Pump — how many jobs one claim loop runs at once | 20 |
| `Pumps` | Number of independent claim loops the group runs | 1 |

The distinction matters when you size a group. `PoolSize` is *execution* concurrency and `Pumps` is *fetch-loop* parallelism: each Pump runs its own pool, so a group's node-local ceiling is effectively `PoolSize × Pumps`, multiplied again across the nodes running the group. A group with `PoolSize = 4` and the default single Pump runs at most four `bulk` jobs at once per node. Splitting a lane into its own group also narrows the blast radius: a group that fail-stops takes down only its own lane, so a defect in `bulk` handlers cannot halt `critical`.

Sizing these numbers — matching `PoolSize` to your workload, adding Pumps, budgeting store connections — is its own topic. See [Scaling & Throughput Tuning](/docs/dashboard-operations/scaling-and-throughput-tuning) rather than guessing here.

## Cap a queue cluster-wide with a Concurrency Limit

`PoolSize` bounds execution on one node, but it says nothing about the cluster as a whole: run three nodes with a pool of four each and a lane can execute twelve jobs at once. When the constraint is global — an upstream API that tolerates only N concurrent calls, a rate-limited partner, a database that must not see more than a handful of a certain job at a time — a per-node pool is the wrong tool. That is what a Concurrency Limit is for.

A Concurrency Limit is a per-Queue, cluster-wide cap on how many of its jobs run simultaneously, enforced at claim time: once a Queue is at its cap, no worker anywhere claims more of its work until a running job finishes. It is a different gate from `PoolSize` and Backpressure — those are each node's own pool capacity, while the Concurrency Limit is one shared counter across the whole cluster. The two kinds of cap are independent; a job must clear both to run. A slot is counted as used while a job is leased and frees the instant the job reaches a terminal state or its lease expires, so a crashed node never leaks a slot — the cap self-heals by construction rather than needing a cleanup sweep.

A Concurrency Limit is configured through the storage layer today; it does not yet have a dedicated hosting or operator surface. If you need to cap a Queue, coordinate with your BackWave setup — treat the cap as an operational configuration on the store rather than something you wire in the `AddBackWave` builder, and expect a first-class surface in a future release.

Reading the cap back is first-class. The Monitor's `GetQueueSettingsAsync` returns each Queue's operational settings — its name, whether it is paused, and its configured Concurrency Limit, which is null when the Queue is uncapped. Pair it with `GetQueueDepthsAsync` for how much work is waiting and in flight per Queue. The dashboard Queues view renders the same numbers live, showing in-use against the cap (and infinity when there is none), so you can watch a capped lane hold at its ceiling under load.

## Operating the lanes

Once the lanes are live you operate them without redeploying. Pausing a Queue is an Operator Action: `BackWaveOperator.PauseQueueAsync` stops the whole cluster from claiming new work off that Queue until `ResumeQueueAsync` lifts it, and jobs already running are left alone to finish. Draining `bulk` before a migration, or holding a lane while an upstream dependency recovers, is a pause and a later resume rather than a config change. See [operator actions](/docs/dashboard-operations/overview#what-operators-can-do) for the full set.

For visibility, the Monitor's queue-depth read shows what is waiting and running per Queue, which is how you confirm a Strict lane is draining, a Weighted split is holding its ratio, or a capped Queue is sitting at its ceiling. The [Monitor API](/docs/dashboard-operations/monitor-api) covers the read surface in full.

## Where to go next

- [Queues](/docs/core-concepts/queues): the model behind named lanes and per-job routing.
- [Worker Groups & Dispatch](/docs/core-concepts/worker-groups): how a group claims, dispatches, and runs work.
- [Dispatch Policies](/docs/reference/dispatch-policies): the full reference for Strict and Weighted.
- [Scaling & Throughput Tuning](/docs/dashboard-operations/scaling-and-throughput-tuning): sizing `PoolSize`, Pumps, and connections.
- [Configuration](/docs/introduction/configuration): every option involved in registering BackWave.
- [Operator actions](/docs/dashboard-operations/overview#what-operators-can-do): pause, resume, and the rest of the runtime controls.
