📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

What is backpressure, and how do you implement it across a .NET system, from in-process queues to message brokers and HTTP APIs?

Backpressure strategies (block, buffer, drop, reject), bounded channels, pull-based streams, broker prefetch and concurrency settings, HTTP 429/503 with Retry-After, and metrics.

Staff / Architect SystemDesign Expert backpressure flow-control messaging resilience

🎯 Key points

  • Backpressure lets slow consumers slow down producers
  • Strategies: block, bounded buffer, drop, reject
  • Bounded Channel<T>, Dataflow BoundedCapacity and IAsyncEnumerable<T> give in-process backpressure
  • Broker prefetch and max concurrency limit consumer intake
  • HTTP APIs reject with 429/503 and Retry-After
  • Size buffers by acceptable delay; shed low-value work first
  • Monitor queue depth, oldest item age and drain rate

⏱ 30-second answer

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.

🗣 Full answer (senior)

How I would answer this in an interview

"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> (WriteAsync blocks, TryWrite rejects, DropOldest drops), Dataflow's BoundedCapacity, or pull-based IAsyncEnumerable<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 with Retry-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."


1. The four strategies

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

2. Backpressure by layer in .NET

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

3. Sizing buffers

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.

4. Making the signal reach the source

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.

5. What to measure

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

Production example

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.

🔍 What the interviewer is checking

Staff-level system design on overload behaviour.

Green flags

  • Names block, buffer, drop, reject and when each fits.
  • Implements with bounded channels, rate limiting and broker settings.
  • Ensures backpressure reaches the source.
  • Uses queue age and drain rate as metrics.

Red flags

  • "Just add a queue" without bounds.
  • No plan for what happens when full.

📚 Learn in depth

1. Backpressure is end-to-end

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.

2. Network-level flow control comes free, if you let it

  • If an ASP.NET Core endpoint reads the request body asynchronously and slowly, Kestrel stops reading from the socket when its pipe buffer is full; TCP windows close; the client slows down. No extra code needed.
  • If you buffer the whole body first (or read synchronously in a hurry), you defeat this and take the memory hit instead.
  • HTTP/2 and gRPC add per-stream windows, so one slow stream doesn't block others on the same connection.

3. Broker consumer settings

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.

4. Load shedding by priority

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.

5. Autoscaling with caps

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.

6. Design checklist

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

7. Version notes

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

8. Try it yourself

  1. Bounded channel of 100; producer at 1,000 items/s, consumer at 100/s. Compare Wait, DropOldest, and TryWrite + reject counting; plot depth and age.
  2. Run the code example and drive /orders with bombardier; count 202 vs 503 responses.
  3. Upload a large body to an endpoint that reads slowly with ReadAsync and observe the client being throttled by TCP.
  4. Configure a Service Bus processor with large vs small PrefetchCount and slow handlers; observe lock-lost redeliveries.
  5. Add an "oldest item age" metric to a queue and alert when it exceeds a threshold.

💻 Code

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.

⚠ Common mistakes

1. Unbounded queues as buffers

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.

2. Blocking HTTP requests on a full internal queue

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.

3. Unlimited broker prefetch

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.

4. Monitoring only queue length

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.

5. Buffers sized by memory, not delay

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.

6. Autoscaling consumers without a cap

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.

7. Clients retrying immediately on 429/503

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.

8. Dropping important data silently

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.

9. Buffering whole request bodies

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.

10. Treating backpressure as a single component's job

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.

🏛 Senior insight: trade-offs

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

🔗 Follow-ups the interviewer may ask

  1. When: Candidate uses bounded channels Why: Checks Channels
    How to answer

    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.

  2. When: Candidate discusses limits per dependency Why: Checks unbounded concurrency
    How to answer

    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.

  3. When: Candidate mentions pull-based streams Why: Checks async streams
    How to answer

    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).

🛠 Real-world problems: how you handle them

Flash sale overwhelming an in-memory order queue
Situation
An [e-commerce order API] queued orders in an unbounded in-memory queue before writing to an ERP that handled [50 orders per second].
Signals
During a [flash sale] the queue grew to [400,000] items; memory exhausted; the service crashed and in-memory orders were lost.
Investigation
Metrics showed intake at [800 orders per second] against an ERP drain rate of 50; nothing pushed back on producers.
Resolution
Moved orders to a durable broker queue, set consumer `MaxConcurrentCalls` and a rate limiter to match ERP capacity, autoscaled consumers within that cap, and returned 503 with `Retry-After` only when the broker was unavailable.
Prevention
Added an SLO on queue age, alerts at [5 minutes] behind, and a flash-sale load test.

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].

↩ Usually asked after

More in Concurrency Production Problems

Last updated 31 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me