Understanding ASP.NET Core Runtime Architecture

Thread Pool and Kestrel Server

ASP.NET Core uses the .NET thread pool and Kestrel as its web server. Kestrel is asynchronous by default but relies heavily on available threads to process incoming HTTP requests. Misuse of synchronous operations or blocking async calls can exhaust the thread pool, leading to starvation.

Middleware and DI Lifecycle

Services in ASP.NET Core are managed via built-in dependency injection. Incorrect lifecycle configuration—such as using scoped services in singleton instances—can cause memory leaks, race conditions, or invalid state sharing.

Common Symptoms

  • High latency or request timeouts under concurrent load
  • Memory usage growing over time without clear leaks
  • Random crashes or exceptions related to service resolution
  • Blocked or unresponsive endpoints due to thread starvation
  • Background hosted services not stopping cleanly

Root Causes

1. Blocking Calls in Async Code Paths

Calling .Result or .Wait() on async methods blocks threads, defeating scalability. This blocks Kestrel’s request pipeline and leads to starvation under load.

2. Improper Service Lifetimes in Dependency Injection

Injecting scoped services into singletons violates DI rules and can cause data contamination or thread safety issues.

3. Unbounded Background Tasks or Hosted Services

Background services running infinite loops or using Task.Run() without CancellationToken prevent graceful shutdown and may consume excessive CPU.

4. Excessive Object Allocation and GC Pressure

High frequency of large object allocation or poor caching strategies lead to increased GC pauses and memory fragmentation.

5. Thread Pool Saturation Due to IO Mismanagement

Using sync I/O for DB access, file operations, or HTTP calls causes threads to block, starving the pool and increasing response times.

Diagnostics and Monitoring

1. Use dotnet-counters for Real-Time Metrics

Monitor thread pool saturation, GC activity, and request throughput in real-time:

dotnet-counters monitor --process-id <pid>

2. Profile Using dotnet-trace and dotnet-gcdump

Capture runtime trace and GC dumps to analyze memory pressure and allocations.

3. Inspect Logs with Structured Logging

Use Serilog or Microsoft.Extensions.Logging to trace slow endpoints, dependency injection errors, and unhandled exceptions.

4. Use EventPipe and Visual Studio Profiler

Analyze call stacks and contention points under load to identify blocking sync code or long-running middleware.

5. Review Health Checks and Diagnostic Endpoints

Implement readiness/liveness endpoints and track hosted service status using the built-in IHealthCheck framework.

Step-by-Step Fix Strategy

1. Make All Code Paths Fully Async

public async Task GetDataAsync()
{
    var result = await _service.FetchFromDbAsync();
    return Ok(result);
}

Ensure no sync-over-async code. Replace .Result and .Wait() with await.

2. Validate Service Lifetimes in Startup

services.AddScoped<IMyService, MyService>();

Never inject Scoped services into Singleton instances. Use constructor injection and validate with runtime analyzers.

3. Correctly Implement Hosted Services

public async Task ExecuteAsync(CancellationToken stoppingToken)
{
    while (!stoppingToken.IsCancellationRequested)
    {
        await DoWorkAsync();
        await Task.Delay(1000, stoppingToken);
    }
}

Always check for cancellation and handle shutdown gracefully.

4. Use Batching and Object Pools

Batch database writes or queue processing. Use ArrayPool<T> or ObjectPool for reusable allocations under heavy load.

5. Offload Blocking Operations

For legacy sync libraries, offload to Task.Run() with caution. Prefer native async clients for DBs and HTTP (e.g., HttpClient with SendAsync()).

Best Practices

  • Use async all the way down the call stack
  • Apply structured logging and correlation IDs for observability
  • Limit scope and frequency of logging in hot paths
  • Benchmark using wrk or bombardier to test endpoint saturation
  • Isolate third-party or legacy blocking code in background queues

Conclusion

ASP.NET Core is a performant and extensible framework, but incorrect usage of async constructs, misconfigured DI lifetimes, and unmanaged background tasks can introduce subtle but impactful issues. By enforcing proper async behavior, carefully managing service scopes, and proactively profiling runtime behavior, teams can ensure highly responsive and scalable ASP.NET Core applications in modern cloud environments.

FAQs

1. Why does my ASP.NET Core app slow down under load?

Likely due to thread pool starvation caused by blocking async calls or sync I/O operations. Check for .Result or .Wait().

2. How do I detect memory leaks?

Use dotnet-gcdump or Visual Studio Profiler to capture heap snapshots and identify large or growing object graphs.

3. What causes hosted services not to stop?

Infinite loops without respecting CancellationToken or unawaited background tasks. Ensure proper shutdown handling.

4. Can I use scoped services in background tasks?

Only if you manually create a service scope using IServiceScopeFactory inside the hosted service.

5. How do I monitor runtime thread usage?

Use dotnet-counters or EventPipe to track thread pool size, queue length, and request concurrency in real-time.