---
name: dotnet-performance
description: Diagnose and fix .NET and ASP.NET Core performance problems — measure first with BenchmarkDotNet, dotnet-counters, dotnet-trace, dotnet-dump and APM/OpenTelemetry, then reduce allocations, fix thread pool starvation and sync-over-async, tune EF Core queries, add caching and output caching, choose GC modes, and weigh Native AOT and ReadyToRun. Use this whenever the user says a .NET API is slow, has high latency, high CPU, growing memory, OutOfMemoryException, GC pauses, timeouts under load, slow startup, thread pool starvation, slow EF Core queries or N+1, or asks about Span<T>, ArrayPool, ValueTask, benchmarks, *.csproj settings like ServerGarbageCollector or PublishAot, Kestrel or HttpClient tuning. Also apply it when reviewing C# hot paths, DbContext queries or LINQ-heavy code for performance, even if the user did not mention speed.
metadata:
  technology: .NET
  type: performance
---

# .NET Performance

Measure first, change one thing, measure again. Most real-world .NET slowness is I/O (queries, chatty HTTP, missing caching) or blocking, not CPU micro-costs — never recommend `Span<T>` rewrites before a profile shows the hot path.

## 1. Measure before touching code

| Question | Tool | Capture |
|---|---|---|
| Is this method faster/leaner? | **BenchmarkDotNet** (`[MemoryDiagnoser]`, Release build, no debugger) | Mean, allocations/op, compare baselines |
| What is the live process doing? | **dotnet-counters** (`dotnet-counters monitor -p <pid>`) | CPU, GC heap size, gen0/1/2 counts, % time in GC, thread pool queue length and thread count, exception rate |
| Where is CPU/wall time going? | **dotnet-trace** (open in PerfView, Visual Studio or speedscope) | Hot stacks, blocked time |
| Memory leak / high memory | **dotnet-gcdump**, **dotnet-dump** (`analyze`, `dumpheap -stat`, `gcroot`) | Top types by size, roots holding them |
| Windows deep dive | **PerfView** | GC events, allocation stacks, ETW |
| Production, per request | APM / **OpenTelemetry** traces and metrics | p95/p99 latency, dependency spans, slow SQL |
| Load | k6, NBomber, Bombardier, JMeter | Throughput and latency percentiles at a target RPS |

Always get: the endpoint/operation, current p95/p99 and target, and whether it's CPU, memory, I/O or waiting.

## 2. Triage table

| Symptom | Likely cause | Check | Fix |
|---|---|---|---|
| Latency climbs under load, CPU low, thread count climbing | **Thread pool starvation** from sync-over-async | Thread pool queue length > 0 in counters; stacks in `.Result`/`.Wait()`/`Task.Run` wrappers | Async all the way; remove blocking calls; don't raise `SetMinThreads` as the fix |
| Endpoint slow, DB time dominant | N+1, missing index, over-fetching | EF logged SQL / trace spans; query plan | Projection, `Include`/split query, index, pagination |
| High % time in GC, gen2 growing | Allocation-heavy hot path, LOH allocations (≥ 85 KB) | `dotnet-counters` GC stats, allocation profile | Pooling, `Span<T>`, streaming, avoid large buffers |
| Memory grows until restart | Leak: static caches, event handlers, unbounded `IMemoryCache`, captured scopes | `gcdump` diff between two snapshots, `gcroot` | Bounded cache with `SizeLimit`, unsubscribe, fix lifetimes |
| Socket exhaustion / DNS stale | `new HttpClient()` per call or static client forever | `netstat` TIME_WAIT counts | `IHttpClientFactory`, `PooledConnectionLifetime` |
| Slow cold start | JIT, reflection-heavy startup, large DI graph | Startup trace | ReadyToRun, trim startup work, Native AOT if compatible |
| High CPU, steady | Hot loop, regex, serialization, logging | CPU trace hot stacks | Source-generated regex/JSON/logging, algorithm fix |
| Timeouts to downstream | No timeout, retry storms | Resilience telemetry | Timeouts, circuit breaker, jittered retries |

## 3. Allocation reduction (only on proven hot paths)

- `Span<T>`/`ReadOnlySpan<char>` and `stackalloc` (small, bounded sizes) for parsing and slicing instead of `Substring`/`Split`.
- `ArrayPool<T>.Shared.Rent/Return` (in `try/finally`) for temporary buffers; `RecyclableMemoryStream` for pooled streams.
- Avoid LINQ in tight loops (delegate + enumerator allocations); use `for`/`foreach` over arrays or `List<T>`. LINQ is fine outside hot paths.
- Strings: `StringBuilder` or interpolated string handlers for building; `string.Create` for fixed layouts; `StringComparison.Ordinal(IgnoreCase)` instead of `ToLower()` comparisons.
- Avoid boxing: generic constraints instead of `object`, no value types through non-generic interfaces, `struct` enumerators.
- Lookups: `FrozenDictionary`/`FrozenSet` (.NET 8+) for read-mostly data; `SearchValues<T>` for character/byte set searches.
- Source generators: `[LoggerMessage]`, `[GeneratedRegex]`, `System.Text.Json` `JsonSerializerContext`.
- `ValueTask` only where the result is usually synchronous and measured to matter.

```csharp
[Benchmark(Baseline = true)] public int Split() => Input.Split(',').Length;
[Benchmark] public int SpanCount() => Input.AsSpan().Count(',') + 1;
```

## 4. Async pitfalls

- Sync-over-async (`.Result`, `.Wait()`, `GetAwaiter().GetResult()`) is the number-one cause of throughput collapse; a blocked request holds a thread while waiting for another thread.
- `Task.Run` in ASP.NET Core request code only adds a thread hop — don't wrap sync work to "make it async".
- Parallelise independent I/O with `Task.WhenAll`; bound concurrency with `Parallel.ForEachAsync` (`MaxDegreeOfParallelism`) or `SemaphoreSlim` — never unbounded fan-out against a database.
- Synchronous I/O (`Stream.Read`, sync EF calls) on request threads is disallowed by default in Kestrel for a reason; don't re-enable it.

## 5. EF Core performance

| Technique | When |
|---|---|
| `AsNoTracking()` + `Select` projection to DTO | Every read-only query — biggest single win |
| Pagination (keyset for deep pages) | Any list endpoint; never return unbounded sets |
| `AsSplitQuery()` | Multiple collection `Include`s causing cartesian explosion |
| `ExecuteUpdateAsync`/`ExecuteDeleteAsync` | Set-based updates/deletes (EF Core 7+) |
| Batching (default in `SaveChanges`) | Many inserts; tune `MaxBatchSize` only if measured |
| `EF.CompileAsyncQuery` | Very hot, identical queries where translation cost shows in profiles |
| `AddDbContextPool` | High-throughput services with cheap contexts |
| Indexes | Match `WHERE`/`ORDER BY`/join columns; verify with the actual execution plan |

Enable `LogTo`/sensitive-free SQL logging in development and count queries per request; more than a handful for one endpoint usually means N+1. Drop to Dapper or raw SQL (`SqlQuery<T>`) for reporting queries EF translates poorly.

## 6. Caching

- `IMemoryCache` for per-instance data; always set expiration and a `SizeLimit` with entry sizes.
- `IDistributedCache` (Redis etc.) when instances must share. **HybridCache** (`Microsoft.Extensions.Caching.Hybrid`, introduced alongside .NET 9) combines both tiers with stampede protection — check the package version available to the project.
- Output caching (`AddOutputCache`, `CacheOutput()`) for whole responses with tag-based eviction; response caching middleware only for HTTP-cache-header semantics.
- Response compression: prefer doing it at the reverse proxy/CDN; if in-app, be aware of BREACH-style risks for HTTPS responses mixing secrets and user input.
- Define invalidation before adding a cache; cache stampede and stale data are bugs too.

## 7. Runtime and hosting settings

| Setting | Default guidance |
|---|---|
| GC mode | Server GC for ASP.NET Core (the default); Workstation for small containers/sidecars/desktop |
| DATAS (dynamic adaptation to app size) | Enabled by default with Server GC from .NET 9; lowers memory in containers — measure throughput if you disable it |
| Container limits | Set CPU/memory limits; the runtime respects them. Consider `GCHeapHardLimit`/`GCHeapHardLimitPercent` only after measuring |
| Tiered compilation / Dynamic PGO | Leave on (default since .NET 8) |
| ReadyToRun | Faster startup, larger binaries; good for scale-to-zero and CLI tools |
| Native AOT | Fastest startup, smallest memory; requires trimming-safe code, source-generated JSON, minimal APIs (no MVC), limited reflection — verify every dependency's AOT support |
| Kestrel | Defaults are sane; set `Limits` (body size, connections) for protection, not speed |
| HttpClient | `IHttpClientFactory`; `SocketsHttpHandler.PooledConnectionLifetime`; `MaxConnectionsPerServer` only if downstream demands it; HTTP/2 multiplexing for gRPC |

## Deliverable format

Report performance work as:
1. **Baseline** — metric, tool and conditions (build, load, data size).
2. **Diagnosis** — evidence (counter values, trace stacks, SQL) pointing to the cause.
3. **Fix** — the smallest change, as a diff, with the trade-off stated.
4. **Result** — the same measurement after the change, and a regression guard (benchmark, load test threshold or alert).

## Anti-patterns to reject

- Optimising without a profile or benchmark; benchmarks in Debug or with a stopwatch loop.
- Raising `ThreadPool.SetMinThreads` to mask sync-over-async.
- `GC.Collect()` calls in application code.
- Tracking queries, `ToList()` before filtering, unbounded result sets, lazy loading in loops.
- Unbounded `IMemoryCache` or static dictionaries used as caches.
- `Span`/`unsafe` micro-optimisations in cold code that hurt readability.
- Enabling Native AOT without checking library compatibility and trimming warnings.
