# Backpressure in .NET: Handling Producers That Outrun Consumers

> Backpressure slows producers when consumers cannot keep up. Use bounded .NET channels to wait for capacity, drop replaceable updates, or reject excess requests with 503. The examples distinguish admission to memory from durable processing and show how to size a queue for a burst.

Published: 2026-10-27. Author: Milan Jovanović.

Canonical: https://milanjovanovic.tech/blog/backpressure-dotnet

**Backpressure** slows producers when consumers fall behind; bounded channels can also drop items or reject new work when waiting is unsuitable.
In .NET, `System.Threading.Channels` lets you configure that behavior, but an in-memory channel does not make accepted work durable.

Suppose a background worker processes 200 items per second and a traffic spike produces 500 per second.
The queue grows by 300 items per second until consumption catches up, the producer slows down, or the application rejects or drops work.

## What Happens When Producers Outrun Consumers?

If producers are faster than consumers **on average**, every finite buffer eventually fills.
Buffers exist to absorb **bursts**, temporary excursions above consumer capacity that average out.

So every queue in your system implicitly answers three questions:

- How big can the backlog get?
- What happens to a producer when the backlog is full?
- What happens to the work that does not fit?

An unbounded queue imposes no application-level capacity limit.
Queue depth and latency can grow until the process exhausts memory, losing work that was held only in memory.

Bounded queues force the real decision.
In .NET, [**System.Threading.Channels**](https://learn.microsoft.com/en-us/dotnet/core/extensions/channels) provides bounded queues without an additional package.
Add `using System.Threading.Channels;` for the examples below.

## Bounded Channels: The Decision, Encoded

An unbounded channel is one line:

```csharp
var channel = Channel.CreateUnbounded<WorkItem>();
```

The bounded version makes you state your policy:

```csharp
var channel = Channel.CreateBounded<WorkItem>(
    new BoundedChannelOptions(capacity: 5_000)
    {
        FullMode = BoundedChannelFullMode.Wait,
        SingleReader = true,
        SingleWriter = false
    });
```

`FullMode` controls what a bounded channel does when it fills:

- **`Wait`**: `WriteAsync` asynchronously waits until space is available. It does not discard items on overflow; cancellation, shutdown, and processing failures still need handling.
- **`DropWrite`**: the new item is discarded when full. Newest data loses.
- **`DropOldest`**: the oldest queued item is evicted to make room. Oldest data loses, freshest survives.
- **`DropNewest`**: the most recently queued item is evicted in favor of the incoming one.

Choose the mode according to whether items may be delayed, discarded, or refused.

## Block, Drop, or Shed: What Each Choice Means

![Decision diagram for a full bounded channel: WriteAsync in Wait mode waits asynchronously, DropOldest discards buffered work, and TryWrite in Wait mode rejects excess work with a 429 or 503](https://milanjovanovic.tech/blogs/articles/backpressure-dotnet/backpressure-strategies.png)

**Block (`Wait`) means "every item matters more than producer speed".**
For orders, payments, or audit events, keep the durable copy in a broker or database until processing succeeds.
Waiting on a channel prevents overflow drops; it does not make accepted work durable.
If the producer is a broker consumer, acknowledge only after successful processing, and bound prefetch so the backlog remains upstream.

If the producer is an HTTP handler, awaiting space also retains that request in memory.
Bound concurrent admissions or reject excess requests so an unlimited number of waiting writers does not become another unbounded queue.

```csharp
// Producer: waits when the channel is full
await channel.Writer.WriteAsync(item, cancellationToken);
```

**Drop means "freshness beats completeness".**
Telemetry samples, live location pings, stock tickers, progress updates: a newer item supersedes older ones, so buffering stale ones is actively harmful.
`DropOldest` is usually the right drop mode, because the newest data is the most valuable.

If you drop, **count what you drop.**
The drop callback lets you measure how much data overload discards:

```csharp
using System.Diagnostics.Metrics;

using var meter = new Meter("TelemetryQueue");
var droppedItems = meter.CreateCounter<long>("queue.dropped");
var channel = Channel.CreateBounded<Telemetry>(
    new BoundedChannelOptions(10_000)
    {
        FullMode = BoundedChannelFullMode.DropOldest
    },
    dropped => droppedItems.Add(1));

public sealed record Telemetry(double Value);
```

Keep the meter alive for the application lifetime and export it through **OpenTelemetry metrics**.

**Shed means "reject at the edge, explicitly".**
When neither blocking nor dropping is acceptable inside the pipeline, refuse the work before accepting it.
For an API, that is `TryWrite` plus a 429 or 503 with `Retry-After`:

```csharp
// Use the WorkQueue service registered in the complete example below.
app.MapPost("/events", (WorkItem item, WorkQueue queue, HttpContext context) =>
{
    if (queue.TryEnqueue(item))
    {
        return Results.Accepted();
    }

    context.Response.Headers.RetryAfter = "1";
    return Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
});
```

`TryWrite` returns `false` when a **Wait-mode** channel is full or completed.
In a drop mode it can return `true` even though an item was discarded, so drop modes cannot implement this admission check.
This endpoint acknowledges only admission to memory; use durable storage before returning `202` when losing an accepted event is unacceptable.
A local .NET 10 check filled a one-item channel, attempted another write, then read the buffered item:

```text
Channels: full Wait-mode rejects; WriteAsync waits; DropOldest returns true and reports loss.
```

This composes with [**rate limiting**](https://milanjovanovic.tech/blog/how-to-use-rate-limiting-in-aspnet-core): rate limits cap the agreed inflow, and shedding handles load that exceeds capacity anyway.

## A Complete Producer-Consumer With Backpressure

Here is the full pattern as a hosted service: bounded channel, blocking producer path, a consumer that reads with `await foreach`.
Create a .NET 10 web project with `dotnet new web -n BackpressureDemo`.
The queue types use `System.Threading.Channels`; hosting and logging namespaces are included by the web project.

```csharp
public sealed record WorkItem(Guid Id);

public sealed class WorkQueue
{
    private readonly Channel<WorkItem> _channel =
        Channel.CreateBounded<WorkItem>(new BoundedChannelOptions(5_000)
        {
            FullMode = BoundedChannelFullMode.Wait,
            SingleReader = true
        });

    public ValueTask EnqueueAsync(WorkItem item, CancellationToken ct) =>
        _channel.Writer.WriteAsync(item, ct);

    public bool TryEnqueue(WorkItem item) =>
        _channel.Writer.TryWrite(item);

    public IAsyncEnumerable<WorkItem> DequeueAllAsync(CancellationToken ct) =>
        _channel.Reader.ReadAllAsync(ct);
}

public sealed class Worker(WorkQueue queue, ILogger<Worker> logger)
    : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        await foreach (var item in queue.DequeueAllAsync(stoppingToken))
        {
            try
            {
                await ProcessAsync(item, stoppingToken);
            }
            catch (Exception ex) when (ex is not OperationCanceledException)
            {
                logger.LogError(ex, "Processing failed for {ItemId}", item.Id);
            }
        }
    }

    private Task ProcessAsync(WorkItem item, CancellationToken ct) =>
        Task.Delay(100, ct); // real work here
}
```

Register the types in `Program.cs`, then add the endpoint above before `app.Run()`:

```csharp
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<WorkQueue>();
builder.Services.AddHostedService<Worker>();
var app = builder.Build();

// Map the /events endpoint here.
app.Run();
```

The worker logs a processing failure and continues, so that item is lost in this demonstration.
Host shutdown also cancels processing without draining the backlog.
Use a durable queue, retries, and a dead-letter policy for work that must survive either case; an [**in-memory message bus**](https://milanjovanovic.tech/blog/lightweight-in-memory-message-bus-using-dotnet-channels) cannot provide those guarantees by itself.

Sizing the capacity: it is burst absorption, so size it in **time**, not items.
5,000 queued items at 200 items per second take 25 seconds to drain if no new work arrives.
During a burst of 500 items per second, the backlog grows by 300 per second, so an empty 5,000-item queue fills in about 16.7 seconds.
Size capacity against that excess arrival rate, acceptable queueing delay, and item size.

## Increasing Consumption Before Dropping Anything

If the downstream dependency has spare capacity, increasing consumption can reduce waiting:

- **More consumers per instance.** Multiple readers on one channel (`SingleReader = false`) or `Parallel.ForEachAsync` over `ReadAllAsync` with bounded parallelism.
- **Batching.** Draining items in groups amortizes per-item costs such as database round trips and can materially improve consumer throughput; see **batch processing in .NET**.
- **Scale out.** When the queue is a real broker, add instances behind the [**competing consumers pattern**](https://milanjovanovic.tech/blog/competing-consumers-pattern-dotnet).

Bound the queue **first**, then optimize.
Measure queue depth and processing latency after increasing concurrency.

## Where Backpressure Already Exists (Respect It)

You are surrounded by backpressure mechanisms that only work if you do not defeat them:

- **TCP flow control**: a slow reader propagates pressure to the writer's socket. Reading a request body slowly is backpressure.
- **Kestrel** can enforce configured connection limits. Request-body minimum data rates protect against slow clients; they do not limit application work queued by fast producers.
- **Message brokers**: RabbitMQ prefetch counts and consumer acknowledgements are backpressure; setting prefetch to 5,000 and buffering messages in memory recreates the unbounded queue you just escaped, inside your process.
- **`IAsyncEnumerable` streaming**: a consumer that awaits between items naturally paces a producer that yields them; see **IAsyncEnumerable in C#**.

Pulling an entire broker backlog into an in-memory list moves overload out of durable storage and into the application process.
Keep the backlog where it is durable and visible.

## Summary

- If producers outrun consumers on average, every finite buffer fills. Size buffers against excess arrival rate and acceptable queueing delay.
- Unbounded queues can turn sustained overload into memory exhaustion. Bound queue capacity and waiting admissions.
- Use **Wait** to avoid overflow drops, **DropOldest** with a drop metric when freshness matters, and **TryWrite on a Wait-mode channel + 429/503** to reject excess work.
- Prefer propagating pressure to durable upstream systems (broker queues, HTTP clients that can retry) over hiding backlog in process memory.

Load-test the rejection path as well as the consumer.
Check that waiting requests, queue memory, and retry traffic remain bounded when the arrival rate stays above capacity.

## Frequently asked questions

### What is backpressure in software systems?

Backpressure is a mechanism for slowing down or rejecting producers when consumers cannot keep up. Instead of buffering unlimited work in memory, the system pushes the pressure back upstream and forces an explicit decision about what happens to excess work.

### Why are unbounded queues dangerous?

Without a capacity bound, sustained overload can grow the backlog until memory runs out. Queueing latency rises before the crash, and work held only in memory is lost. A bounded queue makes the application decide when to wait, discard, or reject.

### What is the difference between the BoundedChannelFullMode options?

Wait makes WriteAsync wait asynchronously for space. DropWrite discards the new item. DropOldest and DropNewest evict an already-queued item to make room. Wait avoids overflow drops, but an in-memory channel still loses queued work on a process crash.

### When should I drop work instead of blocking the producer?

Drop when items lose value quickly or newer ones supersede them, such as live position updates. Await capacity when items must not be dropped on overflow, and keep a durable upstream copy when accepted work must survive a crash. Reject excess requests at the edge when waiting is unsuitable.
