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

📚 Interview question

What is unbounded concurrency, why does it take systems down, and how do you put limits in place in .NET?

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.

Senior Architecture Advanced bulkhead rate-limiting resilience unbounded-concurrency

🎯 Key points

  • Unbounded concurrency starts work proportional to input with no cap
  • It exhausts memory, connection pools, sockets and downstream capacity
  • Little's law: in-flight work = throughput x latency, so slow dependencies multiply load
  • Limit fan-out with Parallel.ForEachAsync or SemaphoreSlim
  • Use bounded channels, inbound rate limiting and MaxConnectionsPerServer
  • Bulkheads isolate each dependency
  • Reject fast with 429/503 and monitor limiter metrics

⏱ 30-second 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.

🗣 Full answer (senior)

How I would answer this in an interview

"Unbounded concurrency means the amount of concurrent work is set by the input rather than by a limit you chose: Task.WhenAll over every item in a list that can be huge, Task.Run per 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/SemaphoreSlim for fan-out, bounded channels for queues, MaxConnectionsPerServer and 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."


1. Where unbounded concurrency comes from

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

2. Why it cascades (Little's law)

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.

3. Limits by layer

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

4. Sizing limits

  1. Start from the dependency's capacity: documented rate limits, connection limits, measured max throughput.
  2. Use Little's law with target latency: limit ≈ target throughput × expected latency (+ headroom).
  3. Load test to find the knee where latency/error rate rises.
  4. Make limits configurable and observable: in-flight, queued, rejected, wait time.

Production example

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.

🔍 What the interviewer is checking

Checks resilience design at senior level.

Green flags

  • Identifies sources of unbounded concurrency.
  • Explains cascading failure with Little's law.
  • Places limits at inbound, fan-out, queue and outbound layers.
  • Prefers fast rejection and monitors limits.

Red flags

  • "Async scales infinitely."
  • No limits on fan-out or queues.

📚 Learn in depth

1. Little's law, applied

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:

  • Sizing: to sustain 500 calls/s to a dependency with 200 ms latency, you need ~100 concurrent calls (plus headroom).
  • Failure analysis: a latency increase directly multiplies in-flight work unless a limit caps it.

2. ASP.NET Core rate limiting

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; });
});

3. Bulkheads for outbound dependencies

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.

4. Reject vs queue

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.

5. Observability

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

6. Version notes

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

7. Try it yourself

  1. Call a local endpoint that delays 2 s with Task.WhenAll over 10,000 items; watch memory, sockets (netstat) and errors.
  2. Replace with Parallel.ForEachAsync at degree 50 and compare.
  3. Add the concurrency limiter to the server; drive it with bombardier -c 200 and count 429s.
  4. Set MaxConnectionsPerServer = 8 on the client and observe connection reuse and queueing.
  5. Compute the limit you'd need via Little's law for your own service's numbers, then validate with a load test.
  6. Add a resilience bulkhead and simulate a slow dependency to confirm other endpoints stay healthy.

💻 Code

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();

⚠ Common mistakes

1. Task.WhenAll over input-sized collections

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

2. Default outbound connection limits

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.

3. Queuing instead of rejecting

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.

4. Limits by guesswork

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.

5. Retries without limits

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.

6. One shared limit for all dependencies

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.

7. Unlimited message-consumer concurrency

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.

8. Limits per instance only

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.

9. No visibility into limiters

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.

10. Believing async removes the need for limits

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.

🏛 Senior insight: trade-offs

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

🔗 Follow-ups the interviewer may ask

  1. When: Candidate uses Parallel.ForEachAsync Why: Checks bounded fan-out
    How to answer

    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.

  2. When: Candidate mentions rejecting or slowing producers Why: Checks backpressure design
    How to 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.

  3. When: Candidate mentions bounded queues 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.

🛠 Real-world problems: how you handle them

Push campaign OOM from Task.WhenAll over millions of recipients
Situation
A [notification service] sent a campaign to [2 million] users with `Task.WhenAll(recipients.Select(SendAsync))`.
Signals
Memory climbed to the container limit in minutes; socket exhaustion errors; pods OOM-killed; campaign half sent with duplicates on retry.
Investigation
A memory dump showed millions of pending tasks and HTTP request objects; the push provider also returned 429s.
Resolution
Rewrote sending with a bounded channel of recipients, [50] concurrent senders and a token-bucket limiter matching the provider's rate limit; added idempotency keys.
Prevention
Added a campaign load test and dashboards for in-flight sends and 429 rate.

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.

↩ Usually asked after

More in Concurrency Production Problems

Last updated 31 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me