Skip to content

// Resilience & Messaging · 10

Backpressure

Producer outpaces consumerleads toProducer is slowed down

Let a slow consumer tell a fast producer to ease off.

How it fits together

Backpressure structureFast produceringest, 5000/sBounded channelcapacity 1000wait when fullHTTP 503Slow consumerdrains 200/s1 write (waits)full: waits2 readenqueue refused
uses / callsfails / error path· numbered steps replay in order
  • Fast producer uses / calls Bounded channel (1 write (waits))
  • Bounded channel fails / error path Fast producer (full: waits)
  • Bounded channel uses / calls Slow consumer (2 read)
  • Fast producer fails / error path HTTP 503 (enqueue refused)

Spot it

  • Memory grows until the process is OOM-killed
  • Queue depth climbs and never drains
  • Latency rises steadily under sustained load

It costs

Upstream sees slowdowns or rejections instead of everything looking fine until it is not.

In code

using System.Threading.Channels;​public sealed class IngestQueue{    private readonly Channel<IngestItem> _channel = Channel.CreateBounded<IngestItem>(        new BoundedChannelOptions(1000)        {            FullMode = BoundedChannelFullMode.Wait,            SingleReader = true,        });​    public ValueTask EnqueueAsync(IngestItem item, CancellationToken ct) =>        _channel.Writer.WriteAsync(item, ct);​    public bool TryEnqueue(IngestItem item) => _channel.Writer.TryWrite(item);​    public async Task RunAsync(Func<IngestItem, Task> handle, CancellationToken ct)    {        await foreach (var item in _channel.Reader.ReadAllAsync(ct))        {            await handle(item);        }    }}
Go deeperThe full explanation and what to ask for · 1 min readExpand

When producers outpace consumers, the difference has to go somewhere: a buffer, a rejection or a slowdown. Backpressure makes that choice explicit and propagates "slow down" upstream instead of letting work pile up.

The classic mistake is an unbounded in-memory queue. It hides overload as rising latency and memory until the process dies and loses everything buffered. Bound every buffer, and decide per stage whether full means wait, drop the oldest, or reject.

Little's Law helps size it: items in flight = throughput × latency. A buffer much larger than what the consumer drains within an acceptable wait only adds latency. Pull-based consumers with prefetch and concurrency caps get backpressure naturally; push-based inputs need rate limiting or load shedding at the edge.

Ask for it

  • bounded queue
  • wait-when-full
  • prefetch count
  • max concurrent calls
  • load shedding
  • 503 with Retry-After

Replace the unbounded in-memory queue in the ingest worker with a bounded one of 1000 that waits when full, cap the message consumer at 16 concurrent calls and prefetch 32, and have the HTTP endpoint return 503 with Retry-After when a non-blocking enqueue fails.