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.
When agents use itUse 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.
Install
Copilot (VS Code, Visual Studio, Copilot CLI and github.com) reads skills from the repository — commit them so the whole team gets them.
npx skills add AGCO-Global/org-skills --skill dotnet-performance -a github-copilot
# or with the org installer (adds .github/skills/dotnet-performance):
npx -y github:AGCO-Global/org-skills add skill dotnet-performance
Uses the open skills CLI. Works with Claude Code, Codex, Cursor, Copilot, Gemini CLI, OpenCode, Windsurf and 60+ others — it asks which agent to install into.
Installs the backend-dotnet-skills plugin, which bundles all Backend / .NET skills and keeps them updated.
/plugin marketplace add AGCO-Global/org-skills
/plugin install backend-dotnet-skills@org-skills
# or just this skill, in this repository:
npx skills add AGCO-Global/org-skills --skill dotnet-performance -a claude-code
Use Download .zip above, then upload it under Customize → Skills → Upload skill. Team and Enterprise admins can sync this repo as a plugin marketplace instead.
Installs into .agents/skills/, which Codex reads.
npx skills add AGCO-Global/org-skills --skill dotnet-performance -a codex
Installs into Cursor's skills folder.
npx skills add AGCO-Global/org-skills --skill dotnet-performance -a cursor
Installs into Gemini CLI's skills folder.
npx skills add AGCO-Global/org-skills --skill dotnet-performance -a gemini-cli
Any tool with rules, instructions or custom prompts: use Copy SKILL.md above and paste it in. It is plain Markdown.
Commands use your normal git sign-in to GitHub, so they work while the repository is private. Node.js 20+ required.
Skill contents
.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)
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.
ValueTask only where the result is usually synchronous and measured to matter.
[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 Includes 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:
Baseline — metric, tool and conditions (build, load, data size).
Diagnosis — evidence (counter values, trace stacks, SQL) pointing to the cause.
Fix — the smallest change, as a diff, with the trade-off stated.
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.
---
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.