Performance Optimization — .NET
Overview
Measure before optimizing. Performance work without measurement is guessing — and guessing leads to premature optimization that adds complexity without improving what matters. Profile first, identify the actual bottleneck, fix it, measure again. Optimize only what measurements prove matters.
.NET gives you two advantages no amount of guessing replaces:
- BenchmarkDotNet for deterministic micro-benchmarks (with statistical analysis built in — if you see a delta smaller than the tool's noise floor, it isn't a real change)
- dotnet-counters / dotnet-trace / PerfView for live process observation without redeploying
Use them. The rest of this skill assumes you will.
When to Use
- Performance requirements exist in the spec (latency SLAs, throughput targets, memory budgets)
- Users or monitoring (Application Insights, Prometheus, OpenTelemetry) report slow behavior
- p95 latency, Gen2 GC rate, thread-pool queue length, or allocated-bytes-per-request regresses
- You suspect a recent change introduced a regression
- Building features that handle large datasets or high traffic
When NOT to use: don't optimize before you have evidence of a problem. Premature optimization adds complexity that costs more than the performance it gains. Readable code beats 3% faster code.
.NET Performance Targets
Set targets as part of the spec, not as an afterthought. Representative targets for an ASP.NET Core service:
| Signal |
Good |
Needs Improvement |
Poor |
| p95 request latency |
≤ 100 ms |
≤ 300 ms |
> 300 ms |
| p99 request latency |
≤ 300 ms |
≤ 1 s |
> 1 s |
| Throughput (RPS, single instance) |
≥ 2 000 |
500–2 000 |
< 500 |
| Allocated bytes / request |
≤ 10 KB |
≤ 100 KB |
> 100 KB |
| Gen2 collections / minute |
0 sustained |
Occasional |
Sustained |
| Thread-pool queue length |
0 sustained |
Brief spikes |
Sustained > 0 |
| Working set (container) |
Well below limit |
Approaches limit |
OOM or throttling |
Desktop-specific (Avalonia, MAUI, WPF):
| Signal |
Good |
Needs Improvement |
Poor |
| Cold start (launch → first interactive frame) |
≤ 1.5 s |
≤ 3 s |
> 3 s |
| Steady-state UI frame time |
≤ 16.7 ms (60 fps) |
≤ 33.3 ms |
> 33.3 ms |
| Session memory growth |
Flat |
Slow leak |
Visible growth |
These are defaults; adjust to your actual product constraints and enforce with CI checks (BenchmarkDotNet gates, Application Insights alerts).
The Optimization Workflow
1. MEASURE → Establish baseline with real data
2. IDENTIFY → Find the actual bottleneck (not assumed)
3. FIX → Address the specific bottleneck
4. VERIFY → Measure again, confirm improvement
5. GUARD → Add monitoring or tests to prevent regression
Step 1: Measure
Pick the tool that matches the symptom. You rarely need only one:
| Symptom |
Tool |
What you get |
| "Is this method allocating?" / "Which is faster, A or B?" |
BenchmarkDotNet |
Mean, allocated bytes, Gen0/1/2 collections, confidence intervals |
| "What is the live process doing right now?" |
dotnet-counters monitor -n MyApp |
Real-time GC rate, thread-pool stats, exceptions/sec, custom counters |
| "Where is time going?" (CPU profile) |
dotnet-trace collect --profile cpu-sampling -p |
SpeedScope / PerfView CPU-sampling profile |
| "What allocated all this memory?" (heap snapshot) |
dotnet-gcdump collect -p |
Heap dump viewable in PerfView / dotnet-gcdump analyze |
| "Deep ETW-level detail on Windows" |
PerfView |
CPU, GC, JIT, TPL, stackwalks |
| "Hot path in a library without a full trace" |
EventPipe (via EventListener) |
Lightweight in-process events |
| "End-to-end request shape in production" |
Application Insights / OpenTelemetry |
Distributed traces with dependency timings |
| "EF Core query shape" |
DbContextOptionsBuilder.LogTo(..., LogLevel.Information).EnableSensitiveDataLogging() or MiniProfiler |
Generated SQL, command duration |
A representative BenchmarkDotNet smoke test:
[MemoryDiagnoser] // Tracks allocated bytes + GC collections
[SimpleJob(baseline: true)] // Use multiple [SimpleJob] attributes to compare runtimes / args
public class TaskFormatBenchmarks
{
private readonly TaskDto _task = new(/* ... */);
[Benchmark(Baseline = true)]
public string Interpolated() =>
$"{_task.Title} ({_task.Status})";
[Benchmark]
public string Concatenated() =>
_task.Title + " (" + _task.Status + ")";
[Benchmark]
public string StringBuilderBased()
{
var sb = new StringBuilder(_task.Title.Length + 16);
sb.Append(_task.Title);
sb.Append(" (");
sb.Append(_task.Status);
sb.Append(')');
return sb.ToString();
}
}
Run: dotnet run -c Release --project bench/MyApp.Bench. Never benchmark in Debug — the JIT is different, inlining is off, and [Conditional("DEBUG")] paths fire.
Step 2: Identify the Bottleneck
Use the symptom to narrow the search:
What is slow?
├── Cold start
│ ├── Too many assemblies loaded? --> dotnet-trace with runtime events,
│ │ check Ready-to-Run / ReadyToRun compilation coverage
│ ├── Static initializers? --> dotnet-trace CPU sampling on first 3 seconds
│ ├── Large configuration graph? --> IOptions bindings with ValidateOnStart firing on every key
│ └── Avalonia / WPF: UI thread doing I/O? --> dotnet-trace + dispatcher profiling
├── Request latency
│ ├── Slow SQL? --> Enable EF Core query logging, capture execution plan,
│ │ check .Include() chains (cartesian explosion)
│ ├── Sync-over-async? --> Search for .Result / .Wait() / .GetAwaiter().GetResult() in the request path
│ ├── Missing AsNoTracking on read paths? --> Check DbContext tracking behavior
│ ├── Missing output caching on a stable endpoint? --> AddOutputCache() in ASP.NET Core 7+
│ └── HttpClient misuse? --> Not using IHttpClientFactory; socket exhaustion at scale
├── Throughput
│ ├── Thread-pool starvation? --> dotnet-counters: System.Runtime.threadpool-queue-length > 0 sustained
│ ├── Lock contention? --> dotnet-trace with contention events; check Parallel.ForEach over shared dict
│ └── Kestrel limits? --> Check MaxConcurrentConnections, MaxConcurrentUpgradedConnections
├── Memory growth
│ ├── Static caches? --> dotnet-gcdump, look for types with unexpected instance counts
│ ├── Event handlers not unsubscribed? --> Especially in Avalonia/WPF view-models
│ ├── Strings cached indefinitely? --> Bounded MemoryCache with size limits, not Dictionary<string, T>
│ └── IDisposable not disposed? --> Analyzer CA2000 / CA1816 off? Turn them on
└── UI frame drops (Avalonia / MAUI / WPF)
├── Big lists not virtualized? --> ListBox with VirtualizingStackPanel (Avalonia default)
├── Binding on hot path? --> Compiled bindings (x:DataType + x:CompileBindings)
├── Heavy work on UI thread? --> Move to Task.Run; marshal results back via Dispatcher.UIThread.InvokeAsync
└── Excessive DynamicResource subscriptions? --> Use StaticResource where the value won't change
Common bottlenecks by category:
ASP.NET Core / general server:
| Symptom |
Likely Cause |
Investigation |
| Slow endpoint |
EF Core N+1, missing indexes, missing AsNoTracking, missing pagination |
Enable EF Core logging; MiniProfiler; check execution plans |
| Gen2 spikes |
Large object allocations (>85 KB), long-lived caches of short-lived data |
dotnet-counters Gen2 rate; dotnet-gcdump for LOH tenants |
| Thread-pool starvation |
Sync-over-async, blocking I/O, too-large Parallel.ForEach |
dotnet-counters threadpool-queue-length; grep for .Result / .Wait() |
| HTTP socket exhaustion |
New HttpClient per request |
Replace with IHttpClientFactory + typed clients |
| High CPU on startup |
R2R disabled, TieredCompilation disabled, heavy reflection |
dotnet-trace CPU sampling; check <PublishReadyToRun> |
| Intermittent latency |
GC STW pauses, lock contention |
dotnet-counters gc-pause-duration; PerfView contention events |
Avalonia / MAUI / WPF:
| Symptom |
Likely Cause |
Investigation |
| Slow cold start |
Many assemblies, no trimming/AOT, heavy constructors |
dotnet-trace StartupPerformance; <PublishTrimmed> / <PublishAot> where supported |
| Frame drops on scroll |
No virtualization, expensive DataTemplate, complex LayoutTransform |
Avalonia RendererDiagnostics; check VirtualizingStackPanel in effect |
| Growing memory during session |
Event handlers on singletons; closed-over captures in bindings |
dotnet-gcdump; search for += on static events without a matching -= |
| UI freeze |
Task.Wait/.Result from the UI thread, or large synchronous computation |
Profile the dispatcher; move work to a background Task and await |
Step 3: Fix Common Anti-Patterns
EF Core N+1 Query
// BAD: N+1 — one query per order for its lines
var orders = await db.Orders.Where(o => o.CustomerId == customerId).ToListAsync();
foreach (var order in orders)
{
order.Lines = await db.OrderLines.Where(l => l.OrderId == order.Id).ToListAsync(); // N queries
}
// GOOD: Single query with Include (watch for cartesian explosion when two .Include siblings both have many rows)
var orders = await db.Orders
.Where(o => o.CustomerId == customerId)
.Include(o => o.Lines)
.ToListAsync();
// GOOD: Split query when two Include chains would Cartesian-explode
var ordersWithEverything = await db.Orders
.Where(o => o.CustomerId == customerId)
.Include(o => o.Lines)
.Include(o => o.Shipments)
.AsSplitQuery() // Issues 1 + N queries — faster than Cartesian when both sides have many rows
.ToListAsync();
// GOOD: Project to a DTO for read paths — fetches only the columns you need
var summaries = await db.Orders
.Where(o => o.CustomerId == customerId)
.Select(o => new OrderSummaryDto(o.Id, o.CreatedAt, o.Lines.Count))
.AsNoTracking()
.ToListAsync();
Sync-Over-Async
// BAD: blocks a thread-pool thread; under load → thread-pool starvation
public IActionResult Get(Guid id)
{
var order = _service.GetByIdAsync(id).Result; // BLOCK
return Ok(order);
}
// GOOD
public async Task<IActionResult> Get(Guid id, CancellationToken cancellationToken)
{
var order = await _service.GetByIdAsync(id, cancellationToken);
return Ok(order);
}
.Result, .Wait(), .GetAwaiter().GetResult() in request-handling or UI paths are a red flag. The only acceptable uses are in composition-root startup synchronization and in documented adapter shims (see deprecation-and-migration).
Library code — add .ConfigureAwait(false). If the method lives in a class library that may be consumed from hosts that capture a SynchronizationContext (WPF, WinForms, MAUI UI thread, Avalonia dispatcher, older ASP.NET-on-.NET-Framework), append .ConfigureAwait(false) to every public await. Without it, the continuation marshals back to the caller's sync context, which (a) adds a thread-pool hop of overhead on each await and (b) sets up the classic deadlock when any upstream caller blocks on the returned Task (.Result / .Wait() / .GetAwaiter().GetResult()). In ASP.NET Core hosts this is a no-op — there is no SynchronizationContext since .NET Core 2.1 — but a library doesn't know which host will consume it, so default the posture to "ConfigureAwait(false) on every public-API await."
// Library method — targets unknown host
public async Task<OrderDto> GetAsync(Guid id, CancellationToken cancellationToken)
{
var row = await _db.Orders
.FindAsync([id], cancellationToken)
.ConfigureAwait(false); // ← don't capture sync context
return row is null ? throw new OrderNotFoundException(id) : row.ToDto();
}
Unbounded Queries / Missing Pagination
// BAD: returns everything
app.MapGet("/api/tasks", async (AppDbContext db) =>
await db.Tasks.ToListAsync());
// GOOD: cap at the boundary, even if the client asks for more
app.MapGet("/api/tasks", async (int page, int pageSize, AppDbContext db) =>
{
var size = Math.Clamp(pageSize, 1, 100); // Never trust clients on page size
var skip = Math.Max(0, page - 1) * size;
var items = await db.Tasks
.OrderByDescending(t => t.CreatedAt)
.Skip(skip)
.Take(size)
.AsNoTracking()
.ToListAsync();
return Results.Ok(items);
});
Allocation Discipline in Hot Paths
// BAD: repeated string concatenation in a loop allocates each step
string name = "";
foreach (var part in parts)
{
name += part + " "; // O(n^2) allocations
}
// GOOD: StringBuilder when the size is unknown
var sb = new StringBuilder(capacity: parts.Count * 8);
foreach (var part in parts)
{
sb.Append(part);
sb.Append(' ');
}
var name = sb.ToString();
// BETTER (when input types are known): interpolation + defined capacity
var name = string.Create(CultureInfo.InvariantCulture, stackalloc char[64],
$"{first} {middle} {last}");
// Parsing: use Span<T> / ReadOnlySpan<char> to avoid substring allocations
ReadOnlySpan<char> input = s.AsSpan();
var commaIndex = input.IndexOf(',');
if (commaIndex < 0) return;
ReadOnlySpan<char> head = input[..commaIndex];
ReadOnlySpan<char> tail = input[(commaIndex + 1)..];
// Buffers that live beyond a stack frame: ArrayPool<T>.Shared
byte[] buffer = ArrayPool<byte>.Shared.Rent(4096);
try
{
// use buffer
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
Turn on [MemoryDiagnoser] on your BenchmarkDotNet runs so every change visibly moves the "Allocated" column. A "3% faster" change that allocates twice as many bytes is not a win.
HttpClient Misuse
// BAD: creates a fresh socket pool every call → socket exhaustion under load
public async Task<UserDto> GetAsync(Guid id, CancellationToken cancellationToken)
{
using var client = new HttpClient { BaseAddress = new Uri("https://api.example.com/") };
return await client.GetFromJsonAsync<UserDto>($"/users/{id}", cancellationToken);
}
// GOOD: register once in the composition root
// Program.cs
builder.Services.AddHttpClient<IUserClient, UserClient>(c =>
c.BaseAddress = new Uri("https://api.example.com/"));
public sealed class UserClient(HttpClient http) : IUserClient
{
public Task<UserDto?> GetAsync(Guid id, CancellationToken cancellationToken) =>
http.GetFromJsonAsync<UserDto>($"/users/{id}", cancellationToken);
}
Output Caching (ASP.NET Core 7+)
// Program.cs
builder.Services.AddOutputCache(options =>
{
options.AddBasePolicy(policy => policy.Expire(TimeSpan.FromSeconds(30)));
});
app.UseOutputCache();
app.MapGet("/api/catalog", async (ICatalogService svc, CancellationToken ct) =>
await svc.ListAsync(ct))
.CacheOutput(policy => policy.Expire(TimeSpan.FromMinutes(1)).SetVaryByQuery("category"));
Use output caching for endpoints whose response is safe to serve stale for a short window; use response caching + [ResponseCache] attributes for CDN-friendly caching.
Regex
// BAD: compiled-at-runtime on every call
if (Regex.IsMatch(input, @"\b[A-Z]{2,}\b")) { … }
// GOOD (.NET 7+): source-generated regex — zero startup cost, typed
[GeneratedRegex(@"\b[A-Z]{2,}\b")]
private static partial Regex AllCapsToken();
if (AllCapsToken().IsMatch(input)) { … }
Avalonia-Specific
- Keep
VirtualizingStackPanel as the items panel for lists of >50 items (Avalonia's ListBox default — don't replace with StackPanel)
- Turn on
x:CompileBindings="True" with x:DataType="..." — makes bindings type-checked and faster
- Prefer
StaticResource for values that don't change at runtime (spacing, icon geometry); reserve DynamicResource for theme-sensitive brushes
- Avoid deep visual trees —
DockPanel / Grid usually replaces nested StackPanel stacks
Kestrel & Hosting
builder.WebHost.ConfigureKestrel(options =>
{
options.Limits.MaxRequestBodySize = 10 * 1024 * 1024; // 10 MB — don't let random uploads dictate memory
options.Limits.MaxConcurrentConnections = 10_000; // Tune to container memory
options.AddServerHeader = false; // Reduce fingerprinting
});
Set <ServerGarbageCollection>true</ServerGarbageCollection> in the host's .csproj (on by default for ASP.NET Core, but re-check for worker services).
Step 4: Verify
Re-run the same measurement you started with. Record the delta — specific numbers, not "feels faster":
BEFORE AFTER
p95 request latency 420 ms → 95 ms
Allocations/request 180 KB → 22 KB
Gen2/min 3 → 0
BenchmarkDotNet Mean 2450 µs ± 140 µs → 310 µs ± 18 µs
If the delta is inside the tool's noise floor, the change isn't real.
Step 5: Guard
Prevent regression:
- Commit the BenchmarkDotNet project to the repo; run it in CI on tag/release with a threshold check
- Add Application Insights / OpenTelemetry alerts on the metrics you care about (p95 latency, Gen2 rate, thread-pool queue)
- Add an analyzer rule if the fix was structural (e.g. Roslyn analyzer for
.Result in a code path that must stay async)
- Document the fix in an ADR if the reasoning was non-obvious
Performance Budget
Set budgets and enforce them in CI:
p95 request latency ≤ 100 ms (per endpoint tag)
Allocated bytes/req ≤ 10 KB (measured via BenchmarkDotNet on the hot path)
Gen2/min 0 sustained (alert fires on > 0 over 10 min)
Cold start (desktop) ≤ 1.5 s
Startup memory ≤ 150 MB working set
Throughput ≥ 2 000 RPS single instance
A CI step for the allocation budget:
- name: BenchmarkDotNet — hot path allocation budget
run: dotnet run -c Release --project bench/MyApp.Bench -- --filter *HotPath* --exporters json
- name: Enforce budget
run: pwsh scripts/check-benchmark-budget.ps1 -Path BenchmarkDotNet.Artifacts/results -MaxBytesPerOp 10240
The script (a few lines of PowerShell) parses the JSON and fails the build when any benchmark exceeds the budget. Tune the numbers to your actual SLAs.
See Also
Common Rationalizations
| Rationalization |
Reality |
| "We'll optimize later" |
Performance debt compounds. Fix obvious anti-patterns (N+1, sync-over-async, unbounded queries) now; defer micro-optimizations until profiling justifies them. |
| "It's fast on my machine" |
Your machine isn't the user's. Profile on representative hardware, and in a container with the same resource limits as production. |
| "This optimization is obvious" |
If you didn't measure, you don't know. Profile first. BenchmarkDotNet exists for a reason. |
| "The framework handles performance" |
ASP.NET Core / EF Core / Avalonia prevent some issues but can't fix your N+1, your .Result, or your unbounded query. |
| "A 3% improvement is great" |
Is it real, or within noise? BenchmarkDotNet's confidence intervals answer that. 3% with a ±5% CI is zero. |
"I'll just Task.Run it" |
Task.Run from an already-pool thread is free overhead (and makes it harder to debug). Use it only when you need to move CPU work off the current thread. |
| "Allocating is fine, GC handles it" |
The GC handles it at the cost of pauses. Gen2 pauses block the thread-pool, which breaks throughput. Track allocations. |
| "I'll figure out why cold start is slow when we ship" |
Cold start is a product decision. Measure it in CI with a dotnet-trace run on the first 3 seconds of startup. |
Red Flags
- Optimization without before/after measurements
.Result / .Wait() / .GetAwaiter().GetResult() in request-handling or UI paths
- EF Core N+1 patterns (loops that call
DbSet inside)
Include chains where both sides have many rows (cartesian explosion) without AsSplitQuery()
- List endpoints without pagination or without a server-enforced
PageSize cap
new HttpClient() per-request
- Benchmarks run in Debug (invalid) or missing
[MemoryDiagnoser]
- BenchmarkDotNet results without confidence intervals (single-run numbers)
- No monitoring on p95 latency, Gen2 rate, thread-pool queue length
- Manual string concatenation in loops; missing
StringBuilder / Span<T> / ArrayPool<T> in hot paths
- Non-virtualized lists in Avalonia / WPF / MAUI
Regex with runtime-compiled patterns in hot paths on .NET 7+ (use [GeneratedRegex])
Verification
After any performance-related change:
Source & Modifications
- Upstream: https://github.com/addyosmani/agent-skills/blob/44dac80216da709913fb410f632a65547866346f/skills/performance-optimization/SKILL.md
- Pinned commit:
44dac80216da709913fb410f632a65547866346f (synced 2026-04-19)
- Status:
rewritten
- Rationale: upstream's concrete tooling is JS/web-specific (Lighthouse, web-vitals RUM library, Core Web Vitals,
<picture> responsive images, React useMemo/memo, bundle size, webpack/Vite tree-shaking). The equivalent .NET surface is entirely different (BenchmarkDotNet, dotnet-counters, dotnet-trace, PerfView, GC generations, EF Core, Kestrel, Span<T>/ArrayPool<T>). The five-step workflow and the "measure before optimizing" principle are preserved; everything else is retargeted.
- What changed (almost everything):
- New skill name (
performance-optimization-dotnet)
- "Core Web Vitals Targets" table replaced with ".NET Performance Targets" — two tables (ASP.NET Core server + desktop UI) covering p95/p99 latency, throughput, allocations/request, Gen2 rate, thread-pool queue, working set, cold start, frame time, session memory growth
- Step 1 "Measure" rewritten as a tool-matrix table (BenchmarkDotNet, dotnet-counters, dotnet-trace, dotnet-gcdump, PerfView, EventPipe, Application Insights/OpenTelemetry, EF Core logging / MiniProfiler); added a representative BenchmarkDotNet smoke test with
[MemoryDiagnoser] + [SimpleJob(baseline: true)]; added the "never benchmark in Debug" warning
- "Where to Start Measuring" decision tree fully rewritten for .NET symptoms (cold start →
dotnet-trace StartupPerformance, request latency → EF Core logging / sync-over-async search, throughput → thread-pool counters / Kestrel limits, memory growth → dotnet-gcdump / event-handler leaks, UI frame drops → virtualization / compiled bindings)
- Step 2 bottleneck tables split into ASP.NET Core / Avalonia-MAUI-WPF
- Step 3 anti-pattern examples fully rewritten:
- N+1 → EF Core
Include / AsSplitQuery / projection to DTO + AsNoTracking
- Sync-over-async (upstream had no equivalent)
- Unbounded query →
Math.Clamp(pageSize, 1, 100) + Skip/Take + AsNoTracking
- Allocation discipline →
StringBuilder, string.Create + stackalloc, ReadOnlySpan<char>, ArrayPool<T>.Shared
- HttpClient misuse →
IHttpClientFactory + typed client
- Output Caching (ASP.NET Core 7+
AddOutputCache) — no upstream analog
- Regex →
[GeneratedRegex] (source-generated, .NET 7+)
- Avalonia-specific → virtualization, compiled bindings, StaticResource vs DynamicResource, visual-tree depth
- Kestrel / hosting →
ConfigureKestrel, <ServerGarbageCollection>
- Removed entirely: upstream's
<picture> / srcset / AVIF/WebP image-optimization section, React re-render / memo / useMemo section, bundle-size / code-splitting / Suspense section, Lighthouse/Web Vitals measurement examples — none apply to .NET
- Step 4 "Verify" adds an explicit before/after table format and a "noise floor" warning
- New Step 5 "Guard" — BenchmarkDotNet in CI, Application Insights alerts, Roslyn analyzer rules, ADRs — expands upstream's one-line "add monitoring or tests"
- "Performance Budget" fully rewritten with .NET-representative numbers and a PowerShell CI step parsing BenchmarkDotNet JSON
- "See Also" adds links to Microsoft Learn diagnostics, BenchmarkDotNet docs, EF Core perf, Kestrel tuning
- Common Rationalizations table adds: noise-vs-real improvements,
Task.Run abuse, Gen2 pause costs, cold-start measurability
- Red Flags list rewritten around .NET smells (
.Result/.Wait(), cartesian Include, Debug-mode benchmarks, per-request HttpClient, runtime-compiled Regex, non-virtualized lists, missing [MemoryDiagnoser])
- Verification checklist adds noise-floor check,
[MemoryDiagnoser] requirement, monitoring dashboard reflection, CI budget pass
- What was preserved conceptually (principles, not content): "measure before optimizing" framing, the MEASURE → IDENTIFY → FIX → VERIFY → GUARD 5-step workflow (added GUARD as a proper step where upstream had it as a sub-bullet), the anti-pattern-fix-example structure, the "Performance Budget + CI enforcement" closing section, Common Rationalizations and Red Flags table shape
- Downstream patches (applied after the initial sync; not tracked against upstream):
- 2026-04-19 (plugin v1.0.4) — Sync-Over-Async anti-pattern section now covers
ConfigureAwait(false) in library code, with an explicit "no-op under ASP.NET Core since .NET Core 2.1" caveat and the deadlock-avoidance rationale. Keeps the skill set internally consistent with code-review-and-quality and incremental-implementation, which already carry the same guidance.
- License: MIT © 2025 Addy Osmani — see
../../LICENSES/agent-skills-MIT.txt
1---2name: performance-optimization-dotnet3description: Optimizes .NET/C# application performance — measure first with BenchmarkDotNet / dotnet-counters / dotnet-trace / PerfView, then fix the specific bottleneck (EF Core N+1, sync-over-async, Gen2 GC pressure, thread-pool starvation, allocation hotspots, unbounded queries, missing pagination, Kestrel misconfiguration). Use when performance requirements exist, when latency / throughput / memory regresses, or when profiling reveals a bottleneck that needs fixing.4---56<!-- Adapted from addyosmani/agent-skills (MIT © 2025 Addy Osmani). This is a STRUCTURAL REWRITE — upstream targets Core Web Vitals / Lighthouse / React render optimization / `<picture>`-based image optimization. This skill retargets the same goals (measure before optimizing, fix the actual bottleneck) to the .NET stack: BenchmarkDotNet, dotnet-counters, dotnet-trace, PerfView, EF Core N+1 triage, allocation discipline, Kestrel tuning. The five-step MEASURE → IDENTIFY → FIX → VERIFY → GUARD workflow and the "measure first" principle survive verbatim; every concrete tool and anti-pattern example is replaced. See the "Source & Modifications" footer for the full delta. -->78# Performance Optimization — .NET910## Overview1112Measure before optimizing. Performance work without measurement is guessing — and guessing leads to premature optimization that adds complexity without improving what matters. Profile first, identify the actual bottleneck, fix it, measure again. Optimize only what measurements prove matters.1314.NET gives you two advantages no amount of guessing replaces:1516- **BenchmarkDotNet** for deterministic micro-benchmarks (with statistical analysis built in — if you see a delta smaller than the tool's noise floor, it isn't a real change)17- **dotnet-counters** / **dotnet-trace** / **PerfView** for live process observation without redeploying1819Use them. The rest of this skill assumes you will.2021## When to Use2223- Performance requirements exist in the spec (latency SLAs, throughput targets, memory budgets)24- Users or monitoring (Application Insights, Prometheus, OpenTelemetry) report slow behavior25- p95 latency, Gen2 GC rate, thread-pool queue length, or allocated-bytes-per-request regresses26- You suspect a recent change introduced a regression27- Building features that handle large datasets or high traffic2829**When NOT to use:** don't optimize before you have evidence of a problem. Premature optimization adds complexity that costs more than the performance it gains. Readable code beats 3% faster code.3031## .NET Performance Targets3233Set targets as part of the spec, not as an afterthought. Representative targets for an ASP.NET Core service:3435| Signal | Good | Needs Improvement | Poor |36|---|---|---|---|37| **p95 request latency** | ≤ 100 ms | ≤ 300 ms | > 300 ms |38| **p99 request latency** | ≤ 300 ms | ≤ 1 s | > 1 s |39| **Throughput (RPS, single instance)** | ≥ 2 000 | 500–2 000 | < 500 |40| **Allocated bytes / request** | ≤ 10 KB | ≤ 100 KB | > 100 KB |41| **Gen2 collections / minute** | 0 sustained | Occasional | Sustained |42| **Thread-pool queue length** | 0 sustained | Brief spikes | Sustained > 0 |43| **Working set (container)** | Well below limit | Approaches limit | OOM or throttling |4445Desktop-specific (Avalonia, MAUI, WPF):4647| Signal | Good | Needs Improvement | Poor |48|---|---|---|---|49| **Cold start (launch → first interactive frame)** | ≤ 1.5 s | ≤ 3 s | > 3 s |50| **Steady-state UI frame time** | ≤ 16.7 ms (60 fps) | ≤ 33.3 ms | > 33.3 ms |51| **Session memory growth** | Flat | Slow leak | Visible growth |5253These are **defaults**; adjust to your actual product constraints and enforce with CI checks (BenchmarkDotNet gates, Application Insights alerts).5455## The Optimization Workflow5657```581. MEASURE → Establish baseline with real data592. IDENTIFY → Find the actual bottleneck (not assumed)603. FIX → Address the specific bottleneck614. VERIFY → Measure again, confirm improvement625. GUARD → Add monitoring or tests to prevent regression63```6465### Step 1: Measure6667Pick the tool that matches the symptom. You rarely need only one:6869| Symptom | Tool | What you get |70|---|---|---|71| "Is this method allocating?" / "Which is faster, A or B?" | **BenchmarkDotNet** | Mean, allocated bytes, Gen0/1/2 collections, confidence intervals |72| "What is the live process doing right now?" | **dotnet-counters monitor -n MyApp** | Real-time GC rate, thread-pool stats, exceptions/sec, custom counters |73| "Where is time going?" (CPU profile) | **dotnet-trace collect --profile cpu-sampling -p <pid>** | SpeedScope / PerfView CPU-sampling profile |74| "What allocated all this memory?" (heap snapshot) | **dotnet-gcdump collect -p <pid>** | Heap dump viewable in PerfView / dotnet-gcdump analyze |75| "Deep ETW-level detail on Windows" | **PerfView** | CPU, GC, JIT, TPL, stackwalks |76| "Hot path in a library without a full trace" | **EventPipe** (via `EventListener`) | Lightweight in-process events |77| "End-to-end request shape in production" | **Application Insights** / **OpenTelemetry** | Distributed traces with dependency timings |78| "EF Core query shape" | `DbContextOptionsBuilder.LogTo(..., LogLevel.Information).EnableSensitiveDataLogging()` or **MiniProfiler** | Generated SQL, command duration |7980A representative BenchmarkDotNet smoke test:8182```csharp83[MemoryDiagnoser] // Tracks allocated bytes + GC collections84[SimpleJob(baseline: true)] // Use multiple [SimpleJob] attributes to compare runtimes / args85public class TaskFormatBenchmarks86{87 private readonly TaskDto _task = new(/* ... */);8889 [Benchmark(Baseline = true)]90 public string Interpolated() =>91 $"{_task.Title} ({_task.Status})";9293 [Benchmark]94 public string Concatenated() =>95 _task.Title + " (" + _task.Status + ")";9697 [Benchmark]98 public string StringBuilderBased()99 {100 var sb = new StringBuilder(_task.Title.Length + 16);101 sb.Append(_task.Title);102 sb.Append(" (");103 sb.Append(_task.Status);104 sb.Append(')');105 return sb.ToString();106 }107}108```109110Run: `dotnet run -c Release --project bench/MyApp.Bench`. **Never** benchmark in Debug — the JIT is different, inlining is off, and `[Conditional("DEBUG")]` paths fire.111112### Step 2: Identify the Bottleneck113114Use the symptom to narrow the search:115116```117What is slow?118├── Cold start119│ ├── Too many assemblies loaded? --> dotnet-trace with runtime events,120│ │ check Ready-to-Run / ReadyToRun compilation coverage121│ ├── Static initializers? --> dotnet-trace CPU sampling on first 3 seconds122│ ├── Large configuration graph? --> IOptions bindings with ValidateOnStart firing on every key123│ └── Avalonia / WPF: UI thread doing I/O? --> dotnet-trace + dispatcher profiling124├── Request latency125│ ├── Slow SQL? --> Enable EF Core query logging, capture execution plan,126│ │ check .Include() chains (cartesian explosion)127│ ├── Sync-over-async? --> Search for .Result / .Wait() / .GetAwaiter().GetResult() in the request path128│ ├── Missing AsNoTracking on read paths? --> Check DbContext tracking behavior129│ ├── Missing output caching on a stable endpoint? --> AddOutputCache() in ASP.NET Core 7+130│ └── HttpClient misuse? --> Not using IHttpClientFactory; socket exhaustion at scale131├── Throughput132│ ├── Thread-pool starvation? --> dotnet-counters: System.Runtime.threadpool-queue-length > 0 sustained133│ ├── Lock contention? --> dotnet-trace with contention events; check Parallel.ForEach over shared dict134│ └── Kestrel limits? --> Check MaxConcurrentConnections, MaxConcurrentUpgradedConnections135├── Memory growth136│ ├── Static caches? --> dotnet-gcdump, look for types with unexpected instance counts137│ ├── Event handlers not unsubscribed? --> Especially in Avalonia/WPF view-models138│ ├── Strings cached indefinitely? --> Bounded MemoryCache with size limits, not Dictionary<string, T>139│ └── IDisposable not disposed? --> Analyzer CA2000 / CA1816 off? Turn them on140└── UI frame drops (Avalonia / MAUI / WPF)141 ├── Big lists not virtualized? --> ListBox with VirtualizingStackPanel (Avalonia default)142 ├── Binding on hot path? --> Compiled bindings (x:DataType + x:CompileBindings)143 ├── Heavy work on UI thread? --> Move to Task.Run; marshal results back via Dispatcher.UIThread.InvokeAsync144 └── Excessive DynamicResource subscriptions? --> Use StaticResource where the value won't change145```146147Common bottlenecks by category:148149**ASP.NET Core / general server:**150151| Symptom | Likely Cause | Investigation |152|---|---|---|153| Slow endpoint | EF Core N+1, missing indexes, missing `AsNoTracking`, missing pagination | Enable EF Core logging; MiniProfiler; check execution plans |154| Gen2 spikes | Large object allocations (>85 KB), long-lived caches of short-lived data | `dotnet-counters` Gen2 rate; `dotnet-gcdump` for LOH tenants |155| Thread-pool starvation | Sync-over-async, blocking I/O, too-large `Parallel.ForEach` | `dotnet-counters` threadpool-queue-length; grep for `.Result` / `.Wait()` |156| HTTP socket exhaustion | New `HttpClient` per request | Replace with `IHttpClientFactory` + typed clients |157| High CPU on startup | R2R disabled, TieredCompilation disabled, heavy reflection | `dotnet-trace` CPU sampling; check `<PublishReadyToRun>` |158| Intermittent latency | GC STW pauses, lock contention | `dotnet-counters` gc-pause-duration; PerfView contention events |159160**Avalonia / MAUI / WPF:**161162| Symptom | Likely Cause | Investigation |163|---|---|---|164| Slow cold start | Many assemblies, no trimming/AOT, heavy constructors | `dotnet-trace StartupPerformance`; `<PublishTrimmed>` / `<PublishAot>` where supported |165| Frame drops on scroll | No virtualization, expensive `DataTemplate`, complex `LayoutTransform` | Avalonia `RendererDiagnostics`; check `VirtualizingStackPanel` in effect |166| Growing memory during session | Event handlers on singletons; closed-over captures in bindings | `dotnet-gcdump`; search for `+=` on static events without a matching `-=` |167| UI freeze | `Task.Wait`/`.Result` from the UI thread, or large synchronous computation | Profile the dispatcher; move work to a background `Task` and `await` |168169### Step 3: Fix Common Anti-Patterns170171#### EF Core N+1 Query172173```csharp174// BAD: N+1 — one query per order for its lines175var orders = await db.Orders.Where(o => o.CustomerId == customerId).ToListAsync();176foreach (var order in orders)177{178 order.Lines = await db.OrderLines.Where(l => l.OrderId == order.Id).ToListAsync(); // N queries179}180181// GOOD: Single query with Include (watch for cartesian explosion when two .Include siblings both have many rows)182var orders = await db.Orders183 .Where(o => o.CustomerId == customerId)184 .Include(o => o.Lines)185 .ToListAsync();186187// GOOD: Split query when two Include chains would Cartesian-explode188var ordersWithEverything = await db.Orders189 .Where(o => o.CustomerId == customerId)190 .Include(o => o.Lines)191 .Include(o => o.Shipments)192 .AsSplitQuery() // Issues 1 + N queries — faster than Cartesian when both sides have many rows193 .ToListAsync();194195// GOOD: Project to a DTO for read paths — fetches only the columns you need196var summaries = await db.Orders197 .Where(o => o.CustomerId == customerId)198 .Select(o => new OrderSummaryDto(o.Id, o.CreatedAt, o.Lines.Count))199 .AsNoTracking()200 .ToListAsync();201```202203#### Sync-Over-Async204205```csharp206// BAD: blocks a thread-pool thread; under load → thread-pool starvation207public IActionResult Get(Guid id)208{209 var order = _service.GetByIdAsync(id).Result; // BLOCK210 return Ok(order);211}212213// GOOD214public async Task<IActionResult> Get(Guid id, CancellationToken cancellationToken)215{216 var order = await _service.GetByIdAsync(id, cancellationToken);217 return Ok(order);218}219```220221`.Result`, `.Wait()`, `.GetAwaiter().GetResult()` in request-handling or UI paths are a red flag. The only acceptable uses are in composition-root startup synchronization and in documented adapter shims (see `deprecation-and-migration`).222223**Library code — add `.ConfigureAwait(false)`.** If the method lives in a class library that may be consumed from hosts that capture a `SynchronizationContext` (WPF, WinForms, MAUI UI thread, Avalonia dispatcher, older ASP.NET-on-.NET-Framework), append `.ConfigureAwait(false)` to every public `await`. Without it, the continuation marshals back to the caller's sync context, which (a) adds a thread-pool hop of overhead on each await and (b) sets up the classic deadlock when any upstream caller blocks on the returned Task (`.Result` / `.Wait()` / `.GetAwaiter().GetResult()`). In **ASP.NET Core hosts** this is a no-op — there is no `SynchronizationContext` since .NET Core 2.1 — but a library doesn't know which host will consume it, so default the posture to "ConfigureAwait(false) on every public-API await."224225```csharp226// Library method — targets unknown host227public async Task<OrderDto> GetAsync(Guid id, CancellationToken cancellationToken)228{229 var row = await _db.Orders230 .FindAsync([id], cancellationToken)231 .ConfigureAwait(false); // ← don't capture sync context232 return row is null ? throw new OrderNotFoundException(id) : row.ToDto();233}234```235236#### Unbounded Queries / Missing Pagination237238```csharp239// BAD: returns everything240app.MapGet("/api/tasks", async (AppDbContext db) =>241 await db.Tasks.ToListAsync());242243// GOOD: cap at the boundary, even if the client asks for more244app.MapGet("/api/tasks", async (int page, int pageSize, AppDbContext db) =>245{246 var size = Math.Clamp(pageSize, 1, 100); // Never trust clients on page size247 var skip = Math.Max(0, page - 1) * size;248249 var items = await db.Tasks250 .OrderByDescending(t => t.CreatedAt)251 .Skip(skip)252 .Take(size)253 .AsNoTracking()254 .ToListAsync();255256 return Results.Ok(items);257});258```259260#### Allocation Discipline in Hot Paths261262```csharp263// BAD: repeated string concatenation in a loop allocates each step264string name = "";265foreach (var part in parts)266{267 name += part + " "; // O(n^2) allocations268}269270// GOOD: StringBuilder when the size is unknown271var sb = new StringBuilder(capacity: parts.Count * 8);272foreach (var part in parts)273{274 sb.Append(part);275 sb.Append(' ');276}277var name = sb.ToString();278279// BETTER (when input types are known): interpolation + defined capacity280var name = string.Create(CultureInfo.InvariantCulture, stackalloc char[64],281 $"{first} {middle} {last}");282283// Parsing: use Span<T> / ReadOnlySpan<char> to avoid substring allocations284ReadOnlySpan<char> input = s.AsSpan();285var commaIndex = input.IndexOf(',');286if (commaIndex < 0) return;287ReadOnlySpan<char> head = input[..commaIndex];288ReadOnlySpan<char> tail = input[(commaIndex + 1)..];289290// Buffers that live beyond a stack frame: ArrayPool<T>.Shared291byte[] buffer = ArrayPool<byte>.Shared.Rent(4096);292try293{294 // use buffer295}296finally297{298 ArrayPool<byte>.Shared.Return(buffer);299}300```301302Turn on `[MemoryDiagnoser]` on your BenchmarkDotNet runs so every change visibly moves the "Allocated" column. A "3% faster" change that allocates twice as many bytes is not a win.303304#### `HttpClient` Misuse305306```csharp307// BAD: creates a fresh socket pool every call → socket exhaustion under load308public async Task<UserDto> GetAsync(Guid id, CancellationToken cancellationToken)309{310 using var client = new HttpClient { BaseAddress = new Uri("https://api.example.com/") };311 return await client.GetFromJsonAsync<UserDto>($"/users/{id}", cancellationToken);312}313314// GOOD: register once in the composition root315// Program.cs316builder.Services.AddHttpClient<IUserClient, UserClient>(c =>317 c.BaseAddress = new Uri("https://api.example.com/"));318319public sealed class UserClient(HttpClient http) : IUserClient320{321 public Task<UserDto?> GetAsync(Guid id, CancellationToken cancellationToken) =>322 http.GetFromJsonAsync<UserDto>($"/users/{id}", cancellationToken);323}324```325326#### Output Caching (ASP.NET Core 7+)327328```csharp329// Program.cs330builder.Services.AddOutputCache(options =>331{332 options.AddBasePolicy(policy => policy.Expire(TimeSpan.FromSeconds(30)));333});334335app.UseOutputCache();336337app.MapGet("/api/catalog", async (ICatalogService svc, CancellationToken ct) =>338 await svc.ListAsync(ct))339 .CacheOutput(policy => policy.Expire(TimeSpan.FromMinutes(1)).SetVaryByQuery("category"));340```341342Use output caching for endpoints whose response is safe to serve stale for a short window; use response caching + `[ResponseCache]` attributes for CDN-friendly caching.343344#### Regex345346```csharp347// BAD: compiled-at-runtime on every call348if (Regex.IsMatch(input, @"\b[A-Z]{2,}\b")) { … }349350// GOOD (.NET 7+): source-generated regex — zero startup cost, typed351[GeneratedRegex(@"\b[A-Z]{2,}\b")]352private static partial Regex AllCapsToken();353354if (AllCapsToken().IsMatch(input)) { … }355```356357#### Avalonia-Specific358359- Keep `VirtualizingStackPanel` as the items panel for lists of >50 items (Avalonia's `ListBox` default — don't replace with `StackPanel`)360- Turn on `x:CompileBindings="True"` with `x:DataType="..."` — makes bindings type-checked **and** faster361- Prefer `StaticResource` for values that don't change at runtime (spacing, icon geometry); reserve `DynamicResource` for theme-sensitive brushes362- Avoid deep visual trees — `DockPanel` / `Grid` usually replaces nested `StackPanel` stacks363364#### Kestrel & Hosting365366```csharp367builder.WebHost.ConfigureKestrel(options =>368{369 options.Limits.MaxRequestBodySize = 10 * 1024 * 1024; // 10 MB — don't let random uploads dictate memory370 options.Limits.MaxConcurrentConnections = 10_000; // Tune to container memory371 options.AddServerHeader = false; // Reduce fingerprinting372});373```374375Set `<ServerGarbageCollection>true</ServerGarbageCollection>` in the host's `.csproj` (on by default for ASP.NET Core, but re-check for worker services).376377### Step 4: Verify378379Re-run the same measurement you started with. Record the delta — specific numbers, not "feels faster":380381```382BEFORE AFTER383p95 request latency 420 ms → 95 ms384Allocations/request 180 KB → 22 KB385Gen2/min 3 → 0386BenchmarkDotNet Mean 2450 µs ± 140 µs → 310 µs ± 18 µs387```388389If the delta is inside the tool's noise floor, the change isn't real.390391### Step 5: Guard392393Prevent regression:394395- Commit the BenchmarkDotNet project to the repo; run it in CI on tag/release with a threshold check396- Add Application Insights / OpenTelemetry alerts on the metrics you care about (p95 latency, Gen2 rate, thread-pool queue)397- Add an analyzer rule if the fix was structural (e.g. Roslyn analyzer for `.Result` in a code path that must stay async)398- Document the fix in an ADR if the reasoning was non-obvious399400## Performance Budget401402Set budgets and enforce them in CI:403404```405p95 request latency ≤ 100 ms (per endpoint tag)406Allocated bytes/req ≤ 10 KB (measured via BenchmarkDotNet on the hot path)407Gen2/min 0 sustained (alert fires on > 0 over 10 min)408Cold start (desktop) ≤ 1.5 s409Startup memory ≤ 150 MB working set410Throughput ≥ 2 000 RPS single instance411```412413A CI step for the allocation budget:414415```yaml416- name: BenchmarkDotNet — hot path allocation budget417 run: dotnet run -c Release --project bench/MyApp.Bench -- --filter *HotPath* --exporters json418- name: Enforce budget419 run: pwsh scripts/check-benchmark-budget.ps1 -Path BenchmarkDotNet.Artifacts/results -MaxBytesPerOp 10240420```421422The script (a few lines of PowerShell) parses the JSON and fails the build when any benchmark exceeds the budget. Tune the numbers to your actual SLAs.423424## See Also425426- Upstream performance-checklist reference (generic, pre-dates this adaptation): https://github.com/addyosmani/agent-skills/blob/44dac80216da709913fb410f632a65547866346f/references/performance-checklist.md427- [`integration-testing-dotnet`](../integration-testing-dotnet/SKILL.md) — the testing harness you'll layer performance assertions into428- [`shipping-and-launch`](../shipping-and-launch/SKILL.md) — the rollout thresholds table that consumes these metrics429- .NET performance docs: https://learn.microsoft.com/dotnet/core/diagnostics/430- BenchmarkDotNet: https://benchmarkdotnet.org/431- EF Core performance: https://learn.microsoft.com/ef/core/performance/432- Kestrel tuning: https://learn.microsoft.com/aspnet/core/fundamentals/servers/kestrel/options433434## Common Rationalizations435436| Rationalization | Reality |437|---|---|438| "We'll optimize later" | Performance debt compounds. Fix obvious anti-patterns (N+1, sync-over-async, unbounded queries) now; defer micro-optimizations until profiling justifies them. |439| "It's fast on my machine" | Your machine isn't the user's. Profile on representative hardware, and in a container with the same resource limits as production. |440| "This optimization is obvious" | If you didn't measure, you don't know. Profile first. BenchmarkDotNet exists for a reason. |441| "The framework handles performance" | ASP.NET Core / EF Core / Avalonia prevent some issues but can't fix your N+1, your `.Result`, or your unbounded query. |442| "A 3% improvement is great" | Is it real, or within noise? BenchmarkDotNet's confidence intervals answer that. 3% with a ±5% CI is zero. |443| "I'll just `Task.Run` it" | `Task.Run` from an already-pool thread is free overhead (and makes it harder to debug). Use it only when you need to move CPU work off the current thread. |444| "Allocating is fine, GC handles it" | The GC handles it at the cost of pauses. Gen2 pauses block the thread-pool, which breaks throughput. Track allocations. |445| "I'll figure out why cold start is slow when we ship" | Cold start is a product decision. Measure it in CI with a dotnet-trace run on the first 3 seconds of startup. |446447## Red Flags448449- Optimization without before/after measurements450- `.Result` / `.Wait()` / `.GetAwaiter().GetResult()` in request-handling or UI paths451- EF Core N+1 patterns (loops that call `DbSet` inside)452- `Include` chains where both sides have many rows (cartesian explosion) without `AsSplitQuery()`453- List endpoints without pagination or without a server-enforced `PageSize` cap454- `new HttpClient()` per-request455- Benchmarks run in Debug (invalid) or missing `[MemoryDiagnoser]`456- BenchmarkDotNet results without confidence intervals (single-run numbers)457- No monitoring on p95 latency, Gen2 rate, thread-pool queue length458- Manual string concatenation in loops; missing `StringBuilder` / `Span<T>` / `ArrayPool<T>` in hot paths459- Non-virtualized lists in Avalonia / WPF / MAUI460- `Regex` with runtime-compiled patterns in hot paths on .NET 7+ (use `[GeneratedRegex]`)461462## Verification463464After any performance-related change:465466- [ ] Before and after measurements exist (specific numbers with tool-reported variance)467- [ ] The specific bottleneck was identified from a profile, not guessed468- [ ] The fix addresses the identified bottleneck, not a symptom elsewhere469- [ ] No N+1 patterns in new data-access code; all list endpoints paginate with a `PageSize` cap470- [ ] No `.Result` / `.Wait()` introduced in request-handling or UI paths471- [ ] BenchmarkDotNet run was in Release mode with `[MemoryDiagnoser]`472- [ ] Confidence intervals in BenchmarkDotNet output show the delta is outside the noise floor473- [ ] Monitoring dashboards reflect the new baseline (p95 latency, allocation rate, GC rate)474- [ ] Existing tests still pass (`dotnet test`) — optimization didn't break behavior475- [ ] Performance budget in CI still passes476477---478479## Source & Modifications480481- **Upstream**: https://github.com/addyosmani/agent-skills/blob/44dac80216da709913fb410f632a65547866346f/skills/performance-optimization/SKILL.md482- **Pinned commit**: `44dac80216da709913fb410f632a65547866346f` (synced 2026-04-19)483- **Status**: `rewritten`484- **Rationale**: upstream's concrete tooling is JS/web-specific (Lighthouse, web-vitals RUM library, Core Web Vitals, `<picture>` responsive images, React `useMemo`/`memo`, bundle size, webpack/Vite tree-shaking). The equivalent .NET surface is entirely different (BenchmarkDotNet, dotnet-counters, dotnet-trace, PerfView, GC generations, EF Core, Kestrel, `Span<T>`/`ArrayPool<T>`). The five-step workflow and the "measure before optimizing" principle are preserved; everything else is retargeted.485- **What changed (almost everything)**:486 - New skill name (`performance-optimization-dotnet`)487 - "Core Web Vitals Targets" table replaced with ".NET Performance Targets" — two tables (ASP.NET Core server + desktop UI) covering p95/p99 latency, throughput, allocations/request, Gen2 rate, thread-pool queue, working set, cold start, frame time, session memory growth488 - Step 1 "Measure" rewritten as a tool-matrix table (BenchmarkDotNet, dotnet-counters, dotnet-trace, dotnet-gcdump, PerfView, EventPipe, Application Insights/OpenTelemetry, EF Core logging / MiniProfiler); added a representative BenchmarkDotNet smoke test with `[MemoryDiagnoser]` + `[SimpleJob(baseline: true)]`; added the "never benchmark in Debug" warning489 - "Where to Start Measuring" decision tree fully rewritten for .NET symptoms (cold start → `dotnet-trace StartupPerformance`, request latency → EF Core logging / sync-over-async search, throughput → thread-pool counters / Kestrel limits, memory growth → `dotnet-gcdump` / event-handler leaks, UI frame drops → virtualization / compiled bindings)490 - Step 2 bottleneck tables split into ASP.NET Core / Avalonia-MAUI-WPF491 - Step 3 anti-pattern examples fully rewritten:492 - N+1 → EF Core `Include` / `AsSplitQuery` / projection to DTO + `AsNoTracking`493 - Sync-over-async (upstream had no equivalent)494 - Unbounded query → `Math.Clamp(pageSize, 1, 100)` + `Skip`/`Take` + `AsNoTracking`495 - Allocation discipline → `StringBuilder`, `string.Create` + `stackalloc`, `ReadOnlySpan<char>`, `ArrayPool<T>.Shared`496 - HttpClient misuse → `IHttpClientFactory` + typed client497 - Output Caching (ASP.NET Core 7+ `AddOutputCache`) — no upstream analog498 - Regex → `[GeneratedRegex]` (source-generated, .NET 7+)499 - Avalonia-specific → virtualization, compiled bindings, StaticResource vs DynamicResource, visual-tree depth500 - Kestrel / hosting → `ConfigureKestrel`, `<ServerGarbageCollection>`501 - Removed entirely: upstream's `<picture>` / `srcset` / AVIF/WebP image-optimization section, React re-render / `memo` / `useMemo` section, bundle-size / code-splitting / `Suspense` section, Lighthouse/Web Vitals measurement examples — none apply to .NET502 - Step 4 "Verify" adds an explicit before/after table format and a "noise floor" warning503 - New Step 5 "Guard" — BenchmarkDotNet in CI, Application Insights alerts, Roslyn analyzer rules, ADRs — expands upstream's one-line "add monitoring or tests"504 - "Performance Budget" fully rewritten with .NET-representative numbers and a PowerShell CI step parsing BenchmarkDotNet JSON505 - "See Also" adds links to Microsoft Learn diagnostics, BenchmarkDotNet docs, EF Core perf, Kestrel tuning506 - Common Rationalizations table adds: noise-vs-real improvements, `Task.Run` abuse, Gen2 pause costs, cold-start measurability507 - Red Flags list rewritten around .NET smells (`.Result`/`.Wait()`, cartesian `Include`, Debug-mode benchmarks, per-request `HttpClient`, runtime-compiled `Regex`, non-virtualized lists, missing `[MemoryDiagnoser]`)508 - Verification checklist adds noise-floor check, `[MemoryDiagnoser]` requirement, monitoring dashboard reflection, CI budget pass509- **What was preserved conceptually** (principles, not content): "measure before optimizing" framing, the MEASURE → IDENTIFY → FIX → VERIFY → GUARD 5-step workflow (added GUARD as a proper step where upstream had it as a sub-bullet), the anti-pattern-fix-example structure, the "Performance Budget + CI enforcement" closing section, Common Rationalizations and Red Flags table shape510- **Downstream patches** (applied after the initial sync; not tracked against upstream):511 - **2026-04-19** (plugin v1.0.4) — Sync-Over-Async anti-pattern section now covers `ConfigureAwait(false)` in library code, with an explicit "no-op under ASP.NET Core since .NET Core 2.1" caveat and the deadlock-avoidance rationale. Keeps the skill set internally consistent with `code-review-and-quality` and `incremental-implementation`, which already carry the same guidance.512- **License**: MIT © 2025 Addy Osmani — see [`../../LICENSES/agent-skills-MIT.txt`](../../LICENSES/agent-skills-MIT.txt)