Backpressure strategies (block, buffer, drop, reject), bounded channels, pull-based streams, broker prefetch and concurrency settings, HTTP 429/503 with Retry-After, and metrics.
Backpressure is a way for a slow consumer to make producers slow down, instead of letting work pile up until something breaks. The four strategies are block (producer waits), bounded buffer, drop (shed lower-value work) and reject (tell the caller to retry later). In .NET: bounded Channel<T> with WriteAsync (block), BoundedChannelFullMode.DropOldest (drop), TryWrite plus HTTP 429/503 with Retry-After (reject), pull-based IAsyncEnumerable<T>, and broker settings such as prefetch count and max concurrent calls so consumers only take what they can process. Every boundary needs a chosen strategy and metrics for queue depth and age.
"Backpressure is how a slow consumer makes producers slow down, instead of work piling up silently until memory or a dependency breaks. Every boundary where data crosses from a faster part of the system to a slower one needs an explicit choice between four strategies. Block: the producer waits for space. Bounded buffer: absorb bursts up to a known size. Drop: shed lower-value or stale work. Reject: tell the caller to retry later. In .NET, in-process I use a bounded
Channel<T>(WriteAsyncblocks,TryWriterejects,DropOldestdrops), Dataflow'sBoundedCapacity, or pull-basedIAsyncEnumerable<T>. At the network level, TCP and HTTP/2 flow control already push back if I read asynchronously at my own pace. For messaging, the broker is the durable buffer, and consumer prefetch and max-concurrency settings stop consumers taking more than they can process. For HTTP APIs, rate limiting returns 429/503 withRetry-After. The staff-level points are that the signal has to reach the original producer, that buffers are sized by acceptable delay rather than memory, and that queue age is the metric that maps to user impact."
| Strategy | How | Good for | Risk |
|---|---|---|---|
| Block | Producer awaits space (WriteAsync, SendAsync) |
Internal pipelines where producers can wait | Upstream timeouts if the producer is an HTTP request |
| Bounded buffer | Queue up to N, then another strategy | Smoothing bursts | Adds up to N / drain-rate of latency |
| Drop | Discard oldest/newest/sampled | Telemetry, latest-value data, best-effort work | Data loss by design |
| Reject | 429/503 + Retry-After; TryWrite false |
Public/interactive APIs | Clients must back off correctly |
| Layer | Mechanism |
|---|---|
| In-process queue | Bounded Channel<T> (Wait, DropOldest, DropNewest, DropWrite, TryWrite) |
| In-process pipeline | TPL Dataflow BoundedCapacity + SendAsync |
| Streams | IAsyncEnumerable<T> (pull: producer advances only when asked) |
| Network I/O | TCP flow control, HTTP/2 stream windows, System.IO.Pipelines pause/resume thresholds (Kestrel) |
| gRPC streaming | Flow control via HTTP/2; consumer reading pace |
| RabbitMQ | Consumer prefetch (QoS) limits unacked messages |
| Azure Service Bus | PrefetchCount, MaxConcurrentCalls on ServiceBusProcessor |
| Kafka | Pull-based; batch size; pause/resume partitions when local buffers are full |
| HTTP API inbound | ASP.NET Core rate limiter (concurrency/token bucket) + queue limit + 429/503 |
| HTTP API outbound | Resilience concurrency/rate limiters; honour downstream Retry-After |
| Autoscaling | Scale consumers on queue depth/age (KEDA), capped to downstream capacity |
| Buffer | Drain rate | Worst-case added delay |
|---|---|---|
| 1,000 items | 100/s | 10 s |
| 10,000 items | 100/s | 100 s |
| 400,000 items | 50/s | > 2 hours |
Size buffers by the delay you can tolerate, not by how much memory you have.
| Producer | Effective backpressure |
|---|---|
| Internal component | Block on a bounded channel |
| HTTP client (another service) | Reject with 429/503 + Retry-After; client uses backoff with jitter |
| Browser / mobile app | Reject + client-side retry/backoff UX |
| Message publisher | Durable broker absorbs; publisher throttled by broker quotas if needed |
| Upstream batch job | Rate limiter or token bucket on its submission API |
A bounded queue in the middle that blocks only helps if the producer above it can also slow down; otherwise, blocking just turns into timeouts and retries.
| Metric | Why |
|---|---|
| Queue depth | Load vs capacity |
| Oldest item age | Direct user impact ("orders are 10 minutes behind") |
| Drain rate / throughput | How fast you recover |
| Rejections and drops | Strategies firing |
| Consumer concurrency and errors | Whether consumers are the bottleneck |
Our [e-commerce order API] accepted orders over HTTP and pushed them into an unbounded in-memory queue before writing to an ERP that could handle about [50 orders/second]. During a flash sale, intake reached [~800 orders/second]; the queue grew to [400,000] items, memory ran out, the service crashed, and in-memory orders were lost. I redesigned the boundary. The API now writes each order to Azure Service Bus (a durable buffer). Consumers use MaxConcurrentCalls and a rate limiter matched to the ERP's capacity, with KEDA autoscaling consumers within that cap. The API returns 503 with Retry-After only when the broker itself is unavailable. We added an SLO on queue age with alerts at [5 minutes] behind. The next sale queued [hundreds of thousands] of orders safely and drained in [about 40 minutes] with no loss.
Staff-level system design on overload behaviour.
Green flags
Red flags
client ──► API ──► [queue] ──► consumer ──► ERP (50/s)
▲ │ │ │ │
│ reject 429/503 bounded/ prefetch, rate limit,
│ + Retry-After durable concurrency bulkhead
└── backoff with jitter
Each arrow needs a decision. The weakest boundary determines what happens under overload.
var processor = client.CreateProcessor("orders", new ServiceBusProcessorOptions
{
MaxConcurrentCalls = 8, // matched to ERP capacity (with rate limiter)
PrefetchCount = 16, // small: don't hoard messages whose locks may expire
AutoCompleteMessages = false,
MaxAutoLockRenewalDuration = TimeSpan.FromMinutes(5)
});
Too much prefetch means a consumer holds messages it can't process before their locks expire → redelivery → duplicates. Consumers must be idempotent anyway, because at-least-once delivery is normal.
| Under pressure, shed first | Keep |
|---|---|
| Analytics, recommendations, non-critical enrichment | Checkout, payments |
| Retries | First attempts |
| Background refresh | User-initiated requests |
| Low-tier tenants (per contract) | Premium tiers |
Implement with separate queues/limiters per class, priority channels (.NET 9 adds an unbounded prioritised channel), or partitioned rate limiters.
Scale consumers on queue age/depth (KEDA scalers for Service Bus, RabbitMQ, Kafka), but cap the maximum so you don't overwhelm the downstream system you're protecting: 50 consumers × 8 concurrent calls = 400 concurrent ERP calls, which may be exactly the overload you were trying to avoid.
| Boundary | Questions |
|---|---|
| Client → API | Reject or queue? 429 vs 503? Retry-After? Client backoff? |
| API → queue | Durable or in-memory? Bounded? What when full? |
| Queue → consumer | Prefetch, concurrency, lock duration, idempotency |
| Consumer → dependency | Rate limit, bulkhead, timeout, circuit breaker |
| Scaling | Metric, min/max, cooldown |
| Observability | Depth, oldest age, drain rate, drops, rejects |
| Version | Change |
|---|---|
| .NET Core 2.1 | System.IO.Pipelines (Kestrel's flow control) |
| .NET Core 3.0 | Channels, IAsyncEnumerable<T> |
| .NET 7 | ASP.NET Core rate limiting |
| .NET 8 | Resilience pipelines (Polly v8) |
| .NET 9 | Prioritised unbounded channel |
| .NET 10 | Same building blocks |
Wait, DropOldest, and TryWrite + reject counting; plot depth and age./orders with bombardier; count 202 vs 503 responses.ReadAsync and observe the client being throttled by TCP.PrefetchCount and slow handlers; observe lock-lost redeliveries.using System.Diagnostics;
using System.Threading.Channels;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton(_ => Channel.CreateBounded<Order>(new BoundedChannelOptions(1_000)
{
FullMode = BoundedChannelFullMode.Wait,
SingleReader = false
}));
builder.Services.AddHostedService<ErpWriter>();
var app = builder.Build();
// Reject at the edge when full: the client backs off instead of timing out.
app.MapPost("/orders", (Order order, Channel<Order> queue, HttpResponse response) =>
{
if (queue.Writer.TryWrite(order with { AcceptedAt = Stopwatch.GetTimestamp() }))
return Results.Accepted();
response.Headers.RetryAfter = "5";
return Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
});
app.Run();
public sealed record Order(int Id, decimal Total, long AcceptedAt = 0);
// Consumers sized to downstream capacity; queue age is the key metric.
public sealed class ErpWriter(Channel<Order> queue, ILogger<ErpWriter> log) : BackgroundService
{
protected override Task ExecuteAsync(CancellationToken stoppingToken) =>
Task.WhenAll(Enumerable.Range(0, 4).Select(_ => ConsumeAsync(stoppingToken)));
private async Task ConsumeAsync(CancellationToken ct)
{
await foreach (var order in queue.Reader.ReadAllAsync(ct))
{
var age = Stopwatch.GetElapsedTime(order.AcceptedAt);
if (age > TimeSpan.FromSeconds(30))
log.LogWarning("Order {Id} waited {Age} in queue", order.Id, age);
await Task.Delay(20, ct); // ERP call at a rate it can handle
}
}
}
For orders that must never be lost, the bounded channel would be replaced by a durable broker with prefetch and concurrency limits on the consumer.
What happens: Channel.CreateUnbounded / ConcurrentQueue between API and worker.
Why it is wrong: hides overload until memory runs out, then loses everything in it.
Do instead: bound every queue and choose block, drop or reject; use a durable broker for important data.
What happens: the endpoint awaits WriteAsync with no limit.
Why it is wrong: clients time out and retry, adding load.
Do instead: TryWrite + 429/503 with Retry-After, or a short write timeout.
What happens: consumers grab thousands of messages. Why it is wrong: locks expire before processing → redelivery and duplicates; memory spikes. Do instead: small prefetch, concurrency matched to processing capacity, idempotent handlers.
What happens: dashboards show "50,000 messages". Why it is wrong: without drain rate you can't tell if that's 1 minute or 3 hours. Do instead: oldest item age and throughput.
What happens: "we have RAM for a million items". Why it is wrong: a million items may mean hours of latency. Do instead: size by acceptable delay = buffer / drain rate.
What happens: KEDA scales to hundreds of consumers during a backlog. Why it is wrong: overwhelms the downstream system being protected. Do instead: cap max replicas and per-consumer concurrency by downstream capacity.
What happens: no backoff or jitter.
Why it is wrong: retry storms keep the system overloaded.
Do instead: honour Retry-After; exponential backoff with jitter; retry budgets.
What happens: DropOldest on an orders queue.
Why it is wrong: business data lost without trace.
Do instead: drop only low-value data, count drops, and use durable buffers for critical work.
What happens: endpoints read entire uploads into memory before processing. Why it is wrong: defeats TCP/HTTP flow control; memory spikes. Do instead: stream with async reads at the consumer's pace.
What happens: one bounded channel added and declared done. Why it is wrong: overload moves to the next unprotected boundary. Do instead: map every boundary and choose a strategy for each.
Backpressure is a system property, not a library feature. At staff level I map every boundary, choose a strategy per boundary, make sure the signal reaches the original producer, and make queue age a first-class SLO.
| Data type | Strategy |
|---|---|
| Orders, payments | Durable broker buffer, consumer limits, reject only when broker unavailable |
| Telemetry, metrics | Bounded with drop-oldest, sampling |
| Interactive API calls | Reject fast with 429/503 |
| Internal pipelines | Bounded channels with blocking writes |
| Streams | Pull-based IAsyncEnumerable<T>, network flow control |
System.Threading.Channels provides fast, async-friendly queues for passing data between producers and consumers. Channel.CreateBounded<T>(capacity) gives backpressure: when full, WriteAsync waits (or drops items, depending on BoundedChannelFullMode). Channel.CreateUnbounded<T>() never blocks writers but can grow without limit. Writers call Complete() when done; readers loop with await foreach (var x in reader.ReadAllAsync(ct)) and finish when the channel is completed and empty. Options such as SingleReader and SingleWriter enable faster internal implementations. Channels are the standard building block for in-process background queues and pipelines.
Unbounded concurrency means starting as much concurrent work as the input demands, with no cap: Task.WhenAll over every item, Task.Run per request, unbounded queues, unlimited outgoing connections. Under load it exhausts something: memory, database connection pools (100 by default in SqlClient), sockets, downstream rate limits or CPU, and latency collapses for everyone. Little's law (concurrency = throughput × latency) shows that when a dependency slows down, in-flight work grows without bound. Put limits at each layer: SemaphoreSlim or Parallel.ForEachAsync for fan-out, bounded channels for queues, ASP.NET Core rate limiting (concurrency or token bucket) for inbound requests, MaxConnectionsPerServer for outbound HTTP, and bulkheads per dependency.
IAsyncEnumerable<T> is a sequence whose items arrive asynchronously. You produce one with an async iterator method that uses both await and yield return, and consume it with await foreach, which awaits MoveNextAsync() for each item and disposes the enumerator asynchronously. Cancellation is passed with .WithCancellation(ct) and received in the iterator through a parameter marked [EnumeratorCancellation]. It lets you process large or slow data item by item instead of buffering everything, for example EF Core AsAsyncEnumerable() or streaming JSON responses from ASP.NET Core. .NET 10 includes LINQ operators for it in the base library (System.Linq.AsyncEnumerable).
How to tell it (STAR)
In a [flash sale] our order service crashed and lost orders because an unbounded in-memory queue grew far faster than the ERP could drain it. I redesigned it with a durable broker as the buffer, consumer limits matched to ERP capacity, and fast rejection only when the broker was down. The next sale queued [hundreds of thousands] of orders safely and drained in about [40 minutes].
Last updated 31 Oct 2026.