Architect-level performance and scalability interviews stop being about a single request and start being about a fleet of instances under real, uneven load. The questions that separate a strong architect from a strong individual contributor aren't "how do you make this endpoint faster" but "what happens to this design at ten times the traffic, spread across twenty instances, when one dependency gets slow." That's where statelessness assumptions break, connection pools exhaust, thread pools starve while CPU sits idle, and shared secrets like data protection keys turn into cross-instance outages if nobody planned for a farm from day one. Interviewers use these questions to see whether a candidate has actually operated a scaled system, not just read about scaling one. The ten questions below cover statelessness, output caching and compression, rate limiting, HttpClientFactory and socket exhaustion, thread-pool starvation, data protection keys in a farm, load testing and capacity planning.

Q1 What does it mean for an ASP.NET Core service to be stateless, and what state commonly sneaks in that breaks horizontal scaling?#

Short answer: A stateless service can have any instance handle any request with identical results, which requires that nothing a request needs to succeed lives only on the instance that happens to handle it; in practice, state sneaks in through in-process session or cache data, local file writes, the data protection key ring, and anything that quietly assumes "the next request from this user will hit the same box."

The usual suspects, roughly in order of how often they actually cause incidents:

  • In-memory session or IMemoryCache-only caching, which works on one box in testing and then serves stale or missing data once a load balancer spreads traffic.
  • The data protection key ring, generated per-instance by default, which silently breaks authentication cookies and antiforgery tokens across instances unless it's shared explicitly, covered later in this page.
  • Local file system writes, such as uploaded files or an on-disk output cache store, invisible to every other instance and gone entirely on redeploy in most containers.
  • Sticky sessions used to paper over the above, trading the problem for a load-balancing constraint that limits how evenly traffic distributes and how gracefully an instance drains during a deploy.
  • Static, mutable fields used as ad hoc caches or counters, per-process by construction and silently divergent across instances.

The fix is consistent across all of them: push anything that must be consistent into a shared, external store, a distributed cache such as Redis for HybridCache, a blob or key vault for data protection keys, a database for anything durable, and treat the instance itself as disposable.

What interviewers look for: a list grounded in real failure modes, not a definition recited from a textbook, and the instinct to name the data protection key ring specifically, since it's the state that catches the most experienced teams off guard.

Common mistakes: relying on sticky sessions as a permanent fix instead of a stopgap; forgetting that an on-disk output cache store or uploaded file is itself per-instance state.

Follow-up questions:

  • What's the operational cost of sticky sessions that a purely stateless design avoids?
  • How would you migrate a service off in-memory session state without downtime?

Q2 How do you decide between output caching and HybridCache for a given endpoint, and how does compression interact with caching?#

Short answer: Use output caching when many different clients should receive the literal same response, because it skips the endpoint entirely and is trivially shared across instances through a Redis-backed store; use HybridCache when responses are personalized per user but built from expensive shared data, since it caches the underlying data rather than the rendered response, with an in-process L1 layer and an optional distributed L2. Compression should generally run on the cached output, not be recomputed per request, so registering it to wrap the cache is usually the right default.

C#
builder.Services.AddResponseCompression(options =>
{
    options.EnableForHttps = true; // Off by default; weigh BREACH-style risk for the content
});
builder.Services.AddOutputCache(options =>
    options.AddPolicy("Catalog", p => p.Expire(TimeSpan.FromMinutes(10)).Tag("catalog")));

var app = builder.Build();

app.UseResponseCompression(); // Wraps everything below it, including cached bodies
app.UseAuthentication();
app.UseAuthorization();
app.UseOutputCache();          // After auth, or an anonymous user can get a cached personalized page

app.MapGet("/catalog", GetCatalogAsync).CacheOutput("Catalog");

Compressing before caching stores the compressed bytes once and serves them repeatedly at near-zero CPU cost on a hit, the right trade for public, high-traffic endpoints; compressing after caching re-runs Brotli or Gzip on every response and only makes sense when compression genuinely has to vary per client. The BREACH-class risk applies specifically to HTTPS responses mixing attacker-influenced input with a secret in the same compressed body, which is why EnableForHttps defaults to off; enable it deliberately for content, such as a public catalog, where that risk doesn't apply.

What interviewers look for: the caching-layer decision framed around "same response for everyone" versus "same underlying data, different rendering," and awareness that HTTPS compression is an explicit, security-motivated opt-in rather than an oversight.

Common mistakes: using output caching for personalized pages by trying to vary the cache key per user, which usually multiplies cache entries faster than it saves work; enabling HTTPS compression globally without considering BREACH-style exposure for sensitive content.

Follow-up questions:

  • Why is compressing before caching usually cheaper than the reverse?
  • What would make you choose HybridCache over output caching for an endpoint that looks cacheable at first glance?

Q3 Design a rate limiting strategy for a public API with free and paid tiers running on multiple instances.#

Short answer: Partition limits by authenticated identity rather than IP so tiers can have different budgets, chain a per-instance concurrency limiter underneath as a safety valve against local overload, and decide explicitly whether the per-tier numbers need to be exact across the fleet or whether dividing the budget by instance count is good enough, because the built-in limiters keep their counters in process memory.

C#
builder.Services.AddRateLimiter(options =>
{
    options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; // Never leave this at 503

    options.GlobalLimiter = PartitionedRateLimiter.CreateChained(
        PartitionedRateLimiter.Create<HttpContext, string>(http =>
        {
            var userId = http.User.FindFirstValue(ClaimTypes.NameIdentifier);
            var premium = http.User.IsInRole("premium");
            return userId is not null
                ? RateLimitPartition.GetTokenBucketLimiter($"user:{userId}", _ => new()
                {
                    TokenLimit = premium ? 200 : 50,
                    TokensPerPeriod = premium ? 100 : 20,
                    ReplenishmentPeriod = TimeSpan.FromSeconds(10)
                })
                : RateLimitPartition.GetFixedWindowLimiter(
                    $"ip:{http.Connection.RemoteIpAddress}", _ => new() { PermitLimit = 20, Window = TimeSpan.FromMinutes(1) });
        }),
        PartitionedRateLimiter.Create<HttpContext, string>(_ =>
            RateLimitPartition.GetConcurrencyLimiter("instance", _ => new() { PermitLimit = 500, QueueLimit = 100 })));
});

With five instances behind a load balancer, an in-process "100 per minute" policy actually permits up to 500 fleet-wide, depending on how evenly the balancer spreads one client's traffic. For abuse prevention and overload protection that approximation is usually fine, especially paired with the concurrency limiter as a local safety net, and it avoids a network round trip and an availability dependency on every request. When a tier's limit is a billing commitment, enforce it exactly: at a gateway that sees all traffic for a client, or with a Redis-backed limiter so the counter is genuinely shared. Always return 429 with Retry-After, never the default 503, which reads as a health failure to load balancers instead of a deliberate rejection.

What interviewers look for: partitioning by identity rather than IP, explicit reasoning about per-instance approximation versus exact fleet-wide enforcement, and the 429-not-503 detail, which shows operational maturity.

Common mistakes: partitioning by IP alone, which clusters users behind corporate NAT and lets attackers rotate addresses; leaving the default 503 rejection status, which triggers load balancer failover instead of a controlled rejection.

Follow-up questions:

  • When would you enforce a rate limit at a gateway instead of in each service?
  • How do you keep a partition key like an API key header from becoming a memory-exhaustion vector?

Q4 Explain socket exhaustion with HttpClient and how IHttpClientFactory fixes it. What is PooledConnectionLifetime for?#

Short answer: Disposing a new HttpClient per call doesn't close its underlying TCP socket immediately; the socket lingers in TIME_WAIT, and under sustained load a service can exhaust the local ephemeral port range faster than the OS reclaims them, while the opposite mistake, one long-lived static HttpClient, holds connections open indefinitely and never picks up DNS changes for a target that's moved. IHttpClientFactory solves both by managing a pool of SocketsHttpHandler instances behind reusable named or typed clients, recycling each handler after a configurable lifetime so DNS changes are eventually picked up without paying the per-call socket cost.

C#
builder.Services.AddHttpClient<PricingClient>(client =>
{
    client.BaseAddress = new Uri("https://pricing.internal");
    client.Timeout = TimeSpan.FromSeconds(10);
})
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
{
    PooledConnectionLifetime = TimeSpan.FromMinutes(5) // Forces periodic DNS re-resolution
});

public sealed class PricingClient(HttpClient http)
{
    public Task<PriceQuote?> GetQuoteAsync(string sku, CancellationToken ct) =>
        http.GetFromJsonAsync<PriceQuote>($"/quotes/{sku}", ct);
}

PooledConnectionLifetime is what actually resolves the DNS-staleness half: it caps how long a pooled connection can be reused before the handler establishes a fresh one, re-resolving DNS, which matters directly for any downstream behind a load balancer or DNS-based failover. The factory owns the expensive part, the handler and its connection pool, separately from the lightweight HttpClient wrapper, which is never meant to be manually disposed per call.

What interviewers look for: both failure modes named specifically (socket exhaustion from short-lived clients, DNS staleness from a single static client), and PooledConnectionLifetime identified as the fix for the second one, not just "use IHttpClientFactory" as a magic phrase.

Common mistakes: stating only the socket-exhaustion half of the problem and missing the DNS-staleness failure of a single static HttpClient; disposing the HttpClient a factory hands you as if it owned the connection directly.

Follow-up questions:

  • Why doesn't disposing an HttpClient immediately free its socket?
  • How would you add retry and circuit-breaker behavior on top of a factory-managed client?

Q5 What is thread-pool starvation, how do you detect it in production, and what causes it in ASP.NET Core apps specifically?#

Short answer: Thread-pool starvation is when demand for thread-pool threads outpaces how quickly the pool can grow to meet it, so requests queue for a free worker thread while CPU utilization looks low or moderate, producing the confusing signature of rising latency with no obvious resource bottleneck; in ASP.NET Core it's almost always caused by blocking calls, .Result, .Wait() or a synchronous I/O call, tying up a worker thread that should have been released back to the pool while an asynchronous operation completes.

C#
// Wrong: blocks a thread-pool thread for the whole call instead of releasing it.
public IActionResult GetPrice(string sku) =>
    Ok(_pricingClient.GetQuoteAsync(sku).Result); // .Result under load starves the pool

// Right: the thread is released back to the pool while the I/O is in flight.
public async Task<IActionResult> GetPriceAsync(string sku, CancellationToken ct) =>
    Ok(await _pricingClient.GetQuoteAsync(sku, ct));

The pool grows slowly on purpose, injecting new threads gradually rather than all at once, so a sudden burst of blocking calls can starve it faster than it recovers, which is exactly the latency-spike-with-idle-CPU pattern that gets misdiagnosed as a database problem. dotnet-counters monitor --counters System.Runtime surfaces the confirming signature directly: ThreadPool Queue Length climbing while ThreadPool Thread Count barely grows. ThreadPool.SetMinThreads can raise the floor and mask the symptom temporarily, but it doesn't fix the root cause; treat it as a stopgap while you find and fix the blocking call, not a permanent setting.

What interviewers look for: the specific counter-level diagnosis (Queue Length up, CPU not saturated), not just "don't block on async code," and the correct framing of SetMinThreads as a mitigation rather than a fix.

Common mistakes: treating high latency with low CPU as a database problem by default without checking the thread pool; using SetMinThreads as a permanent fix instead of finding the blocking call.

Follow-up questions:

  • Why does Task.Run inside a request handler not fix this, and sometimes make it worse?
  • What's the risk of raising SetMinThreads too aggressively?

Q6 Your load-balanced app randomly logs users out, or throws antiforgery validation failures, after scaling to multiple instances. Diagnose and fix it.#

Short answer: By default each instance generates and stores its own data protection key ring, so a cookie encrypted by instance A can't be decrypted by instance B; without sticky sessions, the next request lands elsewhere and the authentication cookie or antiforgery token fails validation, looking like a random logout. The fix is one shared key ring: a matching application name plus shared storage instead of each instance's local disk.

C#
using Microsoft.AspNetCore.DataProtection;
using StackExchange.Redis;

var redis = await ConnectionMultiplexer.ConnectAsync(builder.Configuration.GetConnectionString("redis")!);

builder.Services.AddDataProtection()
    .SetApplicationName("Shop")                                  // Must match across every instance
    .PersistKeysToStackExchangeRedis(redis, "DataProtection-Keys") // Shared key storage
    .ProtectKeysWithCertificate(loadedCertificate);                // Encrypt keys at rest

SetApplicationName is easy to miss and just as important as shared storage: it sets the application discriminator that keeps unrelated apps' payloads distinct, so if it isn't identical on every instance, shared storage alone still won't let them decrypt each other's cookies. Beyond Redis, the same problem is solved with PersistKeysToAzureBlobStorage or PersistKeysToFileSystem on a UNC share; keys default to a 90-day lifetime via SetDefaultKeyLifetime, and a read-only secondary can call DisableAutomaticKeyGeneration so only a primary rolls new keys. In containers this bites immediately, since every container starts with an empty disk and no shared key ring unless configured from day one.

What interviewers look for: naming the actual root cause (per-instance key ring, not "cookies are broken") and the two-part fix, shared storage plus a matching application name, since storage alone is a common incomplete answer.

Common mistakes: fixing this with sticky sessions instead of a shared key ring, which hides the problem until an instance is drained or scaled down; sharing storage but forgetting SetApplicationName, leaving the discriminator mismatched.

Follow-up questions:

  • What happens to data protected by a key if it's deleted from the shared store while still within its lifetime?
  • Why does ProtectKeysWithCertificate matter even when the store itself is access-controlled?

Q7 How do you load test an ASP.NET Core API so the results actually predict production behavior?#

Short answer: Model the real traffic shape, not just total throughput: use an open-load-model tool that keeps arriving requests independent of how fast the system responds, since a closed model that waits for each response before sending the next hides exactly the queueing behavior you're trying to find, warm up the JIT and connection pools before measuring, and watch saturation signals, CPU, GC pauses, thread-pool queue length and downstream connection pool usage, alongside latency percentiles rather than throughput alone.

A load test that reports "50,000 requests per second, 20ms average latency" and nothing else is close to useless for capacity planning; the P99 and P99.9 tails determine whether real users see timeouts, and average latency can look fine while a real fraction of requests queue behind thread-pool starvation or lock contention. Tools like k6, JMeter, NBomber for .NET-native scenarios, or Azure Load Testing all support open-model generation; run a warm-up period long enough for tiered JIT and connection pools to reach steady state, and run at least one longer soak test, since gradual memory growth or connection leaks only show up after sustained load.

What interviewers look for: the open-versus-closed load model distinction specifically, since it's the detail that separates a load test that predicts production from one that doesn't, plus percentile-based analysis over raw throughput.

Common mistakes: reporting only average latency and total throughput; running a short burst test and calling it done, missing soak-test failure modes like memory growth.

Follow-up questions:

  • Why does a closed-model load generator understate real-world queueing?
  • What would a healthy P50 with an unhealthy P99 tell you about the system?

Q8 Walk through capacity planning for a new service: how do you translate a traffic forecast into instance counts and autoscaling rules?#

Short answer: Start from a load-tested single-instance ceiling at your target latency SLO, not a theoretical maximum, divide expected peak traffic by that ceiling with headroom for N+1 redundancy so losing one instance doesn't exceed the remaining capacity, and set autoscaling triggers on the signal that actually predicts saturation for this workload, often request queue length or CPU, rather than a generic default.

Planning inputWhat it drives
Load-tested RPS per instance at target P99Base instance count for expected peak
N+1 (or N+2) redundancy marginExtra instances so one failure doesn't cause an outage
Downstream connection pool limits (DB, HTTP)A ceiling on how many instances can scale before a dependency saturates first
GC mode and container CPU/memory limitsPer-instance resource requests, and whether DATAS or a heap limit is needed
Autoscaling trigger and cooldownHow fast the fleet reacts to a burst versus how much it thrashes

The step teams skip most often is checking whether a downstream dependency's own limits, a database's max connection count being the classic case, become the real ceiling before compute does; scaling web instances past that point just produces connection pool exhaustion further down the stack. Container CPU limits interact with GC heap sizing in surprising ways: Server GC historically sized itself per available core, so DATAS (on by default with Server GC since .NET 9) and an explicit heap hard limit both matter for predictable memory at the instance sizes you actually run. Load test meaningfully above the forecast, since real traffic is bursty around any average, and revisit the plan after any major dependency or GC-relevant runtime change.

What interviewers look for: capacity planning framed as "load-tested ceiling times headroom, bounded by the slowest downstream dependency," not a made-up instance count, plus awareness that GC and container limits interact.

Common mistakes: planning purely from CPU utilization targets without checking downstream connection pool limits; setting autoscaling triggers on a metric, like memory, that doesn't actually predict this workload's saturation point.

Follow-up questions:

  • What would make request queue length a better autoscaling signal than CPU for a given service?
  • How do you validate a capacity plan before the traffic it's built for actually arrives?

Q9 P99 latency is fine under normal load but spikes hard during bursts, even though average CPU stays low. What's your diagnostic process?#

Short answer: Treat "latency spikes, CPU idle" as a queueing signature, not a compute problem, and check the usual suspects in order of frequency: thread-pool growth lagging a burst of blocking work, a downstream connection pool smaller than the burst's concurrency, and GC pauses on a high-allocation service that only appear once volume crosses a threshold.

The diagnostic sequence that finds it: pull dotnet-counters during a reproduction of the burst and check ThreadPool Queue Length, % Time in GC, and your database driver's or HttpClient handler's pool wait time, since all three produce the same external symptom but need different fixes. A thread-pool signature (queue length climbing, thread count barely moving) points at blocking calls under load; a GC signature (gen2 or LOH collections spiking with the burst) points at an allocation hot path that's fine at steady state but pathological at peak concurrency; a connection-pool signature points at a pool sized for average load, not burst load. The trap is fixing the first plausible cause without confirming it against a counter, since all three look identical from outside.

What interviewers look for: a structured, counter-driven diagnostic process that distinguishes three specific root causes with the same external symptom, rather than a guess.

Common mistakes: jumping straight to "scale up" without diagnosing which of the three causes is actually in play, which often doesn't fix a queueing problem at all.

Follow-up questions:

  • How would you distinguish a thread-pool signature from a connection-pool signature using only dotnet-counters?
  • Why can scaling out make a connection-pool-caused spike worse instead of better?

Q10 You need to scale a stateful component, such as SignalR or an in-memory cache, horizontally. What are your options and trade-offs?#

Short answer: Either move the state out of the process into a shared backend that every instance reads and writes, a Redis backplane for SignalR or a distributed L2 cache for HybridCache, or keep the state local but make routing sticky enough that the same client consistently reaches the same instance; the shared-backend approach scales more cleanly and survives instance loss gracefully, while sticky routing is simpler but reintroduces exactly the per-instance coupling that horizontal scaling is meant to remove.

For SignalR specifically, one instance can hold every connection for a group in memory and broadcast directly with no extra infrastructure, which works until a second instance joins, at which point a backplane, typically Redis, is required so a message published from any instance reaches connections held by every other instance; the trade-off is a hop on every broadcast and a new dependency the feature now relies on. For a cache, HybridCache's local L1 plus a shared L2 offers a middle ground, fast local hits, consistency through the shared layer, but stampede protection and tag-based invalidation are per-instance guarantees for L1, so other instances' local caches can still serve stale data until their local expiration elapses; a system that can't tolerate that window needs shorter local lifetimes, not just "add Redis."

What interviewers look for: the backplane-versus-sticky-routing framing stated as an explicit trade-off, plus the HybridCache nuance that L1 staleness across instances isn't fully solved by adding a shared L2 alone.

Common mistakes: treating "add Redis" as automatically solving every stateful scaling problem without naming what it actually fixes and what it still leaves per-instance; defaulting to sticky sessions as a permanent architecture rather than a transitional step.

Follow-up questions:

  • What happens to in-flight SignalR connections on an instance that's being drained during a deploy?
  • How would you bound the staleness window for other instances' HybridCache L1 entries after an invalidation?

Quick-Fire Round#

QuestionAnswer
What's the most common hidden state that breaks horizontal scaling?The data protection key ring, if not shared explicitly
Should compression run before or after output caching?Before, so the compressed bytes are cached and reused
What should the rate limiter's rejection status code be?429, never the default 503
What setting fixes DNS staleness in a pooled HttpClient?SocketsHttpHandler.PooledConnectionLifetime
What's the tell-tale counter for thread-pool starvation?ThreadPool Queue Length rising while CPU stays low
What two settings share a data protection key ring across instances?SetApplicationName plus a shared persistence store
Why does a closed-model load test understate real risk?It waits for each response, hiding true queueing behavior
What besides compute often becomes the real scaling ceiling?A downstream connection pool, such as the database
What confirms a GC-caused latency spike over a thread-pool one?Gen2/LOH collection spikes correlated with the burst
What does a SignalR backplane actually solve?Delivering messages across instances holding different connections

How to Prepare#

  • Be ready to name specific dotnet-counters signals for thread-pool starvation, GC pressure and connection-pool exhaustion, and to tell them apart.
  • Know the data protection key-ring farm scenario cold: it's one of the most common "why does this only happen in production" questions at this level.
  • Practice reasoning about capacity planning as load-tested-ceiling-times-headroom, bounded by the slowest downstream dependency, not a guessed number.
  • Have a clear, concise explanation of IHttpClientFactory covering both socket exhaustion and DNS staleness, not just one.
  • Rehearse the open-versus-closed load-testing model distinction; it comes up whenever a candidate claims a load test "proves" a capacity number.