DOTNET Contents

Backpressure in Streaming (Don't OOM)

Backpressure is how you avoid buffering yourself into an OOM. If your service streams data or processes queues, you must control how fast producers can push and how slow consumers can pull. Otherwise memory becomes your queue.

On this page

Production incident

A file download endpoint streams large content from an upstream storage service. Someone reads the full response into memory and then writes it to the client. Under a few concurrent downloads, memory spikes and pods get OOMKilled. Another incident: a background consumer reads from a message queue as fast as possible and buffers work in memory while the downstream DB is slow. Memory becomes an unbounded queue. Backpressure was missing.

Symptoms

  • Memory grows with concurrency. OOMKills appear during spikes.
  • GC time increases; throughput drops.
  • Latency increases for all requests because the process is under memory pressure.
  • Streaming endpoints buffer large payloads unexpectedly.

Root causes

  • Reading full content into memory (ReadAsStringAsync/ReadAsByteArrayAsync) for large responses.
  • Unbounded in-memory queues in background processing.
  • No bounded channel / no flow control between producer and consumer.

Diagnosis

# Look for buffering calls
grep -R "ReadAsByteArrayAsync\|ReadAsStringAsync" -n .
grep -R "ToList\(" -n . | head -n 50

# Look for in-memory queues
grep -R "ConcurrentQueue\|BlockingCollection\|List<" -n .

Anti-pattern

// Buffers entire file in memory: OOM waiting to happen
var bytes = await resp.Content.ReadAsByteArrayAsync(ct);
return File(bytes, "application/octet-stream");

Correct pattern

Stream with ResponseHeadersRead and CopyToAsync. For background pipelines, use bounded channels and respect cancellation.

// Streaming download without buffering
var resp = await _http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct);
resp.EnsureSuccessStatusCode();

await using var stream = await resp.Content.ReadAsStreamAsync(ct);
Response.ContentType = "application/octet-stream";
await stream.CopyToAsync(Response.Body, ct);
// Bounded channel for producer/consumer backpressure
var channel = Channel.CreateBounded<WorkItem>(new BoundedChannelOptions(capacity: 500)
{
    FullMode = BoundedChannelFullMode.Wait
});

// Producer writes: will block when full (backpressure)
await channel.Writer.WriteAsync(item, ct);

// Consumer reads: controls throughput
await foreach (var work in channel.Reader.ReadAllAsync(ct))
{
    await ProcessAsync(work, ct);
}

Backpressure design rules

  • Bound everything: queues, buffers, concurrency.
  • Prefer streaming: do not materialize large payloads unless you must.
  • Use time budgets: if the consumer is slow, you either shed load or slow the producer.
  • Expose signals: queue length, channel fullness, drop/reject counts.

Security and performance impact

  • Performance: prevents memory blowups and stabilizes throughput.
  • Security: unbounded buffering is an easy DOS vector. Attackers can request large payloads and starve memory.

Operational notes

  • Monitoring: memory, GC time, queue lengths, dropped/rejected work, streaming response sizes.
  • Rollout: switch buffering endpoints to streaming with canary. Validate content integrity and client behavior.
  • Rollback: if clients require buffering for specific features, keep it behind a limited, authenticated path with strict size limits.

Checklist

  • No large payload buffering on hot paths.
  • Streaming uses ResponseHeadersRead and CopyToAsync.
  • Background pipelines use bounded channels or bounded queues.
  • Queue length and memory pressure are monitored.
  • Size limits exist to prevent abusive payloads.