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 TaskGetDataAsync() { 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
orbombardier
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.