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 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:
var channel = Channel.CreateUnbounded<WorkItem>();
The bounded version makes you state your policy:
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:WriteAsyncasynchronously 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
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.
// 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:
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:
// 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:
Channels: full Wait-mode rejects; WriteAsync waits; DropOldest returns true and reports loss.
This composes with rate limiting: 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.
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():
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 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) orParallel.ForEachAsyncoverReadAllAsyncwith 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.
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.
IAsyncEnumerablestreaming: 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.

