Unbounded Task.WhenAll, Task.Run per request, unlimited queues and connections; Little's law; limits with SemaphoreSlim, Parallel.ForEachAsync, bounded channels, rate limiting middleware and bulkheads.
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.
"Unbounded concurrency means the amount of concurrent work is set by the input rather than by a limit you chose:
Task.WhenAllover every item in a list that can be huge,Task.Runper request or message, unbounded in-memory queues, unlimited outbound HTTP connections, message consumers that pull without limits. It works fine in tests and then takes systems down in production, because something always runs out: memory, connection pools (SqlClient's is 100 by default), sockets and ports, a partner's rate limit, or CPU. And it fails as a cascade. By Little's law (in-flight work = throughput × latency), when a dependency slows from 50 ms to 5 s, in-flight work grows 100×, which slows the dependency further, and timeouts plus retries add even more load. My answer is to put an explicit limit at every layer: inbound rate/concurrency limiting in ASP.NET Core,Parallel.ForEachAsync/SemaphoreSlimfor fan-out, bounded channels for queues,MaxConnectionsPerServerand resilience bulkheads per dependency for outbound calls, and prefetch/concurrency settings for message consumers. I'd rather reject fast with 429/503 than queue forever, and I monitor in-flight work, queue depth and rejections."
| Source | Example | What runs out |
|---|---|---|
| Input-sized fan-out | await Task.WhenAll(ids.Select(CallAsync)) with 2M ids |
Memory, sockets, partner rate limits |
| Work per request/message | _ = Task.Run(...) per request |
CPU, pool threads, memory |
| Unbounded queues | Channel.CreateUnbounded<T>() fed by HTTP |
Memory |
| Outbound HTTP defaults | SocketsHttpHandler.MaxConnectionsPerServer = int.MaxValue |
Ports, partner capacity |
| Database | Thousands of concurrent queries | Connection pool (default Max Pool Size 100) → waits/timeouts |
| Message consumers | Large prefetch, unlimited concurrent handlers | Memory, downstream DB |
| Inbound traffic | No rate/concurrency limits | Everything above, all at once |
| Arrival rate | Latency | In-flight work |
|---|---|---|
| 200 req/s | 100 ms | 20 |
| 200 req/s | 1 s | 200 |
| 200 req/s | 5 s | 1,000 |
Without limits: in-flight grows → memory and connections exhaust → the dependency gets more load → latency rises further → timeouts trigger retries → more load. With a limit (say 100 concurrent calls), the excess becomes fast, controlled rejections or a bounded queue.
| Layer | Tool (.NET) |
|---|---|
| Inbound HTTP | AddRateLimiter (.NET 7+): concurrency, token bucket, fixed/sliding window, partitioned per client; Kestrel MaxConcurrentConnections, request body limits |
| Fan-out in code | Parallel.ForEachAsync (MaxDegreeOfParallelism), shared SemaphoreSlim |
| Internal queues | Bounded Channel<T> with a full mode |
| Outbound HTTP | MaxConnectionsPerServer; resilience pipeline concurrency limiter (bulkhead), rate limiter, circuit breaker |
| Database | Bounded workers; pool size aligned with DB capacity |
| Message consumers | Prefetch count, max concurrent calls (Service Bus MaxConcurrentCalls, RabbitMQ QoS) |
| Background jobs | Fixed worker counts |
limit ≈ target throughput × expected latency (+ headroom).Our [notification service] sent a campaign to [2 million] users with await Task.WhenAll(recipients.Select(SendPushAsync)). Within minutes memory hit the container limit, we saw socket-exhaustion errors and a wall of 429s from the push provider, and the pods were OOM-killed mid-send. Retries then produced duplicates. A memory dump showed millions of pending tasks and request objects. I rebuilt sending around a bounded Channel<Recipient> read by [50] concurrent senders, with a token-bucket rate limiter matching the provider's documented rate, MaxConnectionsPerServer set on the HttpClient, and idempotency keys so retries couldn't duplicate pushes. The next campaign finished in [about 25 minutes] with flat memory and no 429 storms, and we added dashboards for in-flight sends and rejection rates.
Checks resilience design at senior level.
Green flags
Red flags
L = λ × W: average in-flight items (L) = arrival rate (λ) × average time in the system (W). It holds for any stable system: a request pipeline, a queue, a connection pool. Use it two ways:
builder.Services.AddRateLimiter(o =>
{
o.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
o.OnRejected = (ctx, _) =>
{
ctx.HttpContext.Response.Headers.RetryAfter = "5";
return ValueTask.CompletedTask;
};
// Per-client token bucket
o.AddPolicy("per-client", http => RateLimitPartition.GetTokenBucketLimiter(
http.User.Identity?.Name ?? http.Connection.RemoteIpAddress?.ToString() ?? "anon",
_ => new TokenBucketRateLimiterOptions
{
TokenLimit = 100, TokensPerPeriod = 50, ReplenishmentPeriod = TimeSpan.FromSeconds(1),
QueueLimit = 0, AutoReplenishment = true
}));
// Global concurrency limit for an expensive endpoint
o.AddConcurrencyLimiter("reports", l => { l.PermitLimit = 20; l.QueueLimit = 50; });
});
builder.Services.AddHttpClient<PaymentsClient>()
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler { MaxConnectionsPerServer = 32 })
.AddResilienceHandler("payments", p =>
{
p.AddConcurrencyLimiter(permitLimit: 32, queueLimit: 64); // bulkhead
p.AddTimeout(TimeSpan.FromSeconds(2));
p.AddCircuitBreaker(new HttpCircuitBreakerStrategyOptions());
});
Each dependency gets its own limits, so a slow payments API can't consume all connections, threads or memory needed by everything else.
| Strategy | Good when | Risk |
|---|---|---|
Reject immediately (429/503 + Retry-After) |
Interactive APIs, clients that can retry | Requires client cooperation |
| Bounded queue, then reject | Short bursts | Queue time adds latency |
| Unbounded queue | Never for external input | Memory, everything times out together |
A small queue absorbs bursts; a large queue just converts overload into latency and timeouts.
| Metric | Signals |
|---|---|
| In-flight per limiter | Saturation |
| Queue depth / wait time | Building pressure |
| Rejections (429/503) rate | Limits doing their job, or set too low |
| Dependency latency and error rate | Whether limits protect downstream |
| Memory, sockets in use | Unbounded work hiding somewhere |
System.Threading.RateLimiting limiters expose statistics (GetStatistics()), and ASP.NET Core's rate limiting emits metrics in .NET 8+.
| Version | Change |
|---|---|
| .NET Core 2.1 | SocketsHttpHandler (MaxConnectionsPerServer) |
| .NET Core 3.0 | Channels in-box |
| .NET 6 | Parallel.ForEachAsync |
| .NET 7 | System.Threading.RateLimiting, ASP.NET Core rate limiting middleware |
| .NET 8 | Microsoft.Extensions.Http.Resilience (Polly v8); rate limiting metrics |
| .NET 10 | Same building blocks |
Task.WhenAll over 10,000 items; watch memory, sockets (netstat) and errors.Parallel.ForEachAsync at degree 50 and compare.bombardier -c 200 and count 429s.MaxConnectionsPerServer = 8 on the client and observe connection reuse and queueing.using System.Threading.RateLimiting;
using Microsoft.AspNetCore.RateLimiting;
var builder = WebApplication.CreateBuilder(args);
// Inbound limit: at most 20 concurrent report requests, 50 queued, rest rejected with 429.
builder.Services.AddRateLimiter(o =>
{
o.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
o.AddConcurrencyLimiter("reports", l =>
{
l.PermitLimit = 20;
l.QueueLimit = 50;
l.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
});
});
// Outbound limit: cap connections to one partner host.
builder.Services.AddHttpClient("partner")
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler { MaxConnectionsPerServer = 32 });
var app = builder.Build();
app.UseRateLimiter();
app.MapPost("/campaigns/{id:int}/send", async (int id, IHttpClientFactory factory, CancellationToken ct) =>
{
var client = factory.CreateClient("partner");
int[] recipients = Enumerable.Range(1, 10_000).ToArray();
// Fan-out limit instead of Task.WhenAll over everything.
int sent = 0;
await Parallel.ForEachAsync(recipients,
new ParallelOptions { MaxDegreeOfParallelism = 32, CancellationToken = ct },
async (r, token) =>
{
await Task.Delay(5, token); // stands in for client.PostAsync(...)
Interlocked.Increment(ref sent);
});
return Results.Ok(new { Campaign = id, Sent = sent });
});
app.MapGet("/reports", async (CancellationToken ct) =>
{
await Task.Delay(1000, ct);
return "report";
}).RequireRateLimiting("reports");
app.Run();
Task.WhenAll over input-sized collectionsWhat happens: one task per item, all started at once.
Why it is wrong: concurrency grows with input; memory, sockets and downstream capacity exhaust.
Do instead: Parallel.ForEachAsync / SemaphoreSlim with an explicit cap.
What happens: HttpClient with unlimited connections per server.
Why it is wrong: can overwhelm partners or exhaust local ports.
Do instead: MaxConnectionsPerServer and a bulkhead per dependency.
What happens: every overload is absorbed by ever-growing queues.
Why it is wrong: latency climbs until everything times out together; memory grows.
Do instead: bounded queues and fast 429/503 with Retry-After.
What happens: MaxDegreeOfParallelism = 1000 "to be fast".
Why it is wrong: too high doesn't protect; too low wastes capacity.
Do instead: Little's law + load tests; make limits configurable.
What happens: every timeout triggers immediate retries. Why it is wrong: retries multiply load during an outage (retry storms). Do instead: bounded retries with jittered backoff, circuit breakers, retry budgets.
What happens: a global semaphore guards every outbound call. Why it is wrong: a slow dependency consumes all permits and blocks unrelated calls. Do instead: separate bulkheads per dependency.
What happens: consumers prefetch thousands of messages and process them all concurrently. Why it is wrong: memory spikes and database overload. Do instead: tune prefetch and max concurrent handlers to downstream capacity.
What happens: each pod limits to 50, then autoscaling adds 20 pods. Why it is wrong: the downstream sees 1,000 concurrent calls. Do instead: account for instance count; use distributed rate limits for shared dependencies.
What happens: limits exist but nobody sees rejections or saturation. Why it is wrong: you can't tell protection from misconfiguration. Do instead: metrics and alerts on in-flight, queue and rejections.
What happens: "async doesn't use threads, so concurrency is free". Why it is wrong: memory, connections and downstream capacity are still finite. Do instead: bound concurrency explicitly at every layer.
Every resource in a system has a limit; the only question is whether you choose it or the failure chooses it for you. I design explicit limits per layer and per dependency, and treat rejections as a normal, monitored signal.
| Principle | Practice |
|---|---|
| Bound everything | Fan-out, queues, connections, inbound requests |
| Isolate dependencies | Bulkhead per downstream |
| Fail fast | 429/503 with Retry-After |
| Size with data | Little's law plus load tests |
| Observe | In-flight, queue depth, rejections per limiter |
Parallel.ForEachAsync (.NET 6+) runs an async body for each item in a sequence (IEnumerable<T> or IAsyncEnumerable<T>) with a bounded number of concurrent operations, set by ParallelOptions.MaxDegreeOfParallelism (default: processor count). The body receives a CancellationToken that is cancelled if any iteration throws, so it stops early on failure. It is the simplest way to say "process these 10,000 items, at most 20 at a time". Compared with Task.WhenAll it limits concurrency; compared with SemaphoreSlim it needs less code. It does not return results, so write to a concurrent collection or a preallocated array.
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.
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.
How to tell it (STAR)
A [2-million-user] campaign took our notification service down because we started a task for every recipient at once. I rebuilt it with a bounded channel, a fixed number of senders and a rate limiter matching the provider, plus idempotency keys. The next campaign finished in [25 minutes] with flat memory and no 429 storms.
Last updated 31 Oct 2026.