A measured approach to a slow ASP.NET Core endpoint: confirming with Application Insights, profiling CPU, allocations, database and async behaviour in the Visual Studio Performance Profiler, and dotnet-counters and dotnet-trace in production.
First I measure: Application Insights tells me which endpoint is slow and at what percentile, and whether time goes into dependencies like SQL or HTTP. Then I reproduce it on a Release build and run the Visual Studio Performance Profiler (Alt+F2): CPU Usage for hot paths, Database for query count and duration, .NET Object Allocation Tracking for GC pressure, and .NET Async for slow awaits. In an environment where I can't use Visual Studio, dotnet-counters shows live metrics like thread pool queue length and GC, and dotnet-trace captures CPU samples. I fix the biggest cause, often an N+1 query, re-measure against the baseline, and add a load test so it doesn't regress.
"I measure first: Application Insights tells me which endpoint is slow, its p95, p99 and throughput, and whether the time is CPU in my code, waiting on SQL or HTTP, or network latency, and I record a baseline and agree a target. Then I reproduce on a Release build without the debugger and use the Visual Studio Performance Profiler: CPU Usage and flame graphs for hot paths, Database for query counts, .NET Object Allocation Tracking for GC pressure, and .NET Async for slow awaits; where I can't use Visual Studio,
dotnet-countersshows the thread pool queue and GC live anddotnet-tracecaptures CPU samples. If requests stall with low CPU I treat it as a possible deadlock, and memory growth or requests that never complete send me to a dump.For example, after a release our [orders search] p95 went to [2.4 seconds] against a [300 ms] target, and customer service staff were complaining. I owned getting it back on target. Application Insights showed about [180] SQL calls per request; I reproduced it in Release and the profiler's Database tool confirmed an N+1 query, one query per order for its customer. I replaced the loop with one projected query, added a covering index, and re-measured against the baseline. p95 fell to [220 ms], and I added a load test and an alert so it wouldn't regress.
The mistake I see is profiling a Debug build or guessing, so I fix one thing at a time, check p95 and throughput together, and treat performance as continuous, with load tests in the pipeline and a re-baseline after every upgrade."
Build Release, run with production-like data volumes, and drive the endpoint with a load tool (for example k6 or a small HttpClient loop). Debug builds and the debugger change timings significantly.
Debug → Performance Profiler (Alt+F2), choose the startup project or attach to a running process, and select tools:
| Tool | Answers |
|---|---|
| CPU Usage | Which methods burn CPU (hot path, flame graph, caller/callee views) |
| Database | How many queries per request, their durations and text (EF Core and ADO.NET) |
| .NET Object Allocation Tracking | Which code allocates most, and of which types |
| Memory Usage | Heap growth between snapshots |
| .NET Async | Which async operations take longest and where they await |
| .NET Counters | Runtime counters over the session |
| Instrumentation | Exact call counts and durations, at higher overhead |
Rider users get the same with dotTrace and dotMemory integration.
dotnet-counters monitor -p <pid> --counters System.Runtime,Microsoft.AspNetCore.Hosting: request rate, thread pool queue length and thread count, GC heap size, gen 2 collections, exceptions. A growing thread pool queue with modest CPU points to sync-over-async or blocking.dotnet-trace collect -p <pid> --duration 00:00:00:30 --format Speedscope: CPU samples and runtime events you can open in Visual Studio, PerfView or speedscope.Typical causes, in rough order of how often I see them: N+1 queries and missing indexes, sync-over-async or blocking calls starving the thread pool, large allocations (buffering whole responses, ToList() on big queries, string concatenation in loops), chatty downstream calls without caching or batching, and serialisation overhead. Change one thing, re-run the same load and compare to the baseline, then add a load test or a BenchmarkDotNet benchmark to CI for the hot code.
An orders search endpoint had p95 of 2.4 s in Azure Australia East. Application Insights showed 180 SQL dependency calls per request. The Visual Studio Database tool confirmed an N+1: each order lazily loaded its customer. Replacing it with a projection that selected only needed columns in one query, plus a covering index, brought p95 to 220 ms, and allocations per request dropped by 85%.
The interviewer is assessing whether you approach performance scientifically and know the .NET tooling end to end.
Green flags
Red flags
1. MEASURE APM (Application Insights): which endpoint, p95/p99, throughput,
dependency breakdown. Record a baseline. Agree a target.
|
2. LOCATE Is it CPU, waiting, or GC?
| dotnet-counters (cheap, live) --> narrows the category
| profiler / dotnet-trace --> finds the code
|
3. FIX one change at a time, the biggest cause first
|
4. VERIFY same load, same data, compare with the baseline (p95 AND throughput)
|
5. GUARD load test with thresholds in the pipeline, alerts, benchmark
Skipping step 1 is the most common failure: people profile whatever they suspect and optimise code that isn't on the slow path.
request --> Kestrel --> middleware --> endpoint --> service --> EF Core --> SQL Server
| | | | |
CPU CPU CPU await I/O network + query
total latency = CPU in your code
+ time waiting (I/O, locks, thread pool queue)
+ GC pauses
+ network round trips (x number of calls)
| Symptom | Likely category | Best tool |
|---|---|---|
| High CPU, latency scales with load | CPU-bound | CPU Usage, dotnet-trace CPU samples, flame graph |
| Low CPU, high latency | Waiting | APM dependencies, Database tool, .NET Async tool |
| Latency climbs under load, thread pool queue grows | Thread pool starvation | dotnet-counters, then find the blocking call |
| Periodic latency spikes, high gen 2 count | GC pressure | .NET Object Allocation Tracking, Memory Usage |
| Many dependency calls per request | N+1 or chatty calls | APM dependency count, Database tool |
Sampling (CPU Usage, dotnet-trace default) |
Instrumentation | |
|---|---|---|
| How | Records the running stacks at regular intervals | Records every method entry and exit |
| Overhead | Low; safe for short production captures | High; distorts timings of small methods |
| Gives | Statistical share of CPU per method | Exact call counts and durations |
| Misses | Time spent waiting (threads not running) | Nothing, but at a cost |
| Use for | Finding where CPU goes | Understanding how often something is called |
Sampling only sees threads that are running. A request that is slow because it waits on SQL barely shows up in CPU Usage. That is why you switch to the Database or Async tools when CPU looks quiet.
|------------------- Kestrel request processing ------------------|
|---------- OrdersEndpoint.Search ----------|--- JSON serialise ---|
|-- EF Core query --|--- Map to DTO (x180) ---|
|-- string.Format --|
string.Format under your mapper is actionable; a wide Kestrel frame on its own is not.Open with Debug > Performance Profiler (Alt+F2). Run against a Release build, without the debugger.
| Tool | Answers | Watch for |
|---|---|---|
| CPU Usage | Which methods burn CPU | Hot path, flame graph, caller/callee view |
| Database | Queries per request, duration, text | The same query repeated with different parameters (N+1) |
| .NET Object Allocation Tracking | Who allocates what | Large arrays, strings in loops, LINQ in hot paths, boxing |
| Memory Usage | Heap growth between snapshots | Objects that should have gone |
| .NET Async | Long awaits and where they happen | Awaits that take most of the request |
| .NET Counters | Runtime counters during the session | Thread pool queue, GC |
| Instrumentation | Exact call counts | Methods called far more often than expected |
| File I/O | Reads and writes per file | Re-reading configuration or templates per request |
The Diagnostic Tools window shown while debugging is useful for a rough picture, but the debugger changes timings, so don't draw conclusions from it.
dotnet-counters monitor -p <pid> --counters System.Runtime,Microsoft.AspNetCore.Hosting
In .NET 9 and later the System.Runtime meter uses OpenTelemetry-style names. The ones that matter most:
| Counter | What a bad value means |
|---|---|
dotnet.thread_pool.queue.length |
Work waiting for threads; growing = starvation |
dotnet.thread_pool.thread.count |
Rising slowly with the queue = blocking calls |
dotnet.process.cpu.time |
High = CPU-bound |
dotnet.gc.collections (gen 2) |
Frequent = allocation pressure or a leak |
dotnet.gc.heap.total_allocated |
Allocation rate per request |
dotnet.exceptions |
Exceptions used as control flow |
http.server.request.duration |
Request latency from ASP.NET Core |
http.server.active_requests |
Concurrency at the server |
These are the same metrics you can export through OpenTelemetry to Azure Monitor or Prometheus, so you can watch them permanently rather than only during an incident.
dotnet-trace collect -p <pid> --duration 00:00:00:30 --format Speedscope
.nettrace files open in Visual Studio or PerfView; --format Speedscope gives a file for speedscope.app.dotnet-monitor can collect traces through an HTTP API. each request does Task.Delay(200).Wait() <-- blocks a thread for 200 ms
load: 100 requests/second
needs ~20 threads blocked at any time
thread pool starts with ~(CPU count) threads and injects more slowly
result: queue length climbs, latency climbs far beyond 200 ms,
CPU stays moderate
The fix is await Task.Delay(200), which releases the thread while waiting. Raising ThreadPool.SetMinThreads hides the symptom and uses more memory, but the blocking is still there.
| Cause | How it shows up | Fix |
|---|---|---|
| N+1 queries | Dependency count grows with result size | One query with a projection or Include |
| Missing index | One slow query in the Database tool | Index, checked against the query plan |
| Sync-over-async | Thread pool queue grows under load | await all the way |
| Over-fetching | Large allocations, wide SELECT * |
Project only the columns you need; AsNoTracking for reads |
| Chatty downstream calls | Many short HTTP dependencies | Batch, cache, or call in parallel with limits |
| Allocation-heavy code | High allocation rate, gen 2 spikes | Avoid ToList() on large sets, use pooling, spans, StringBuilder |
| Cross-region latency | Each dependency call costs tens of ms | Keep app and database in the same region |
A change can make one request faster but reduce how many requests the service handles, for example by adding parallel calls that use more threads or connections. Always measure both p95 latency and requests per second under the same load before and after, and record the results with the change so later work has a baseline.
Traces and profiler sessions can capture SQL text with parameters, URLs, headers and payloads. Treat trace files like logs that may contain personal information: store them with restricted access and delete them after analysis.
Each .NET release improves the JIT, GC and libraries, so re-baseline after upgrading. Try .NET 11 previews in a benchmark branch, not by profiling production.
/orders/slow and /orders/fast in a loop.dotnet-counters monitor while sending concurrent requests to /report/blocking and watch the thread pool queue length and thread count.await Task.Delay(200) and repeat.dotnet-trace for 30 seconds under load and open it in speedscope to find the widest frames you own.A minimal API with three deliberate problems to profile (EF Core with SQLite so it runs anywhere):
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.0" />
</ItemGroup>
</Project>
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<ShopDb>(o => o.UseSqlite("Data Source=shop.db"));
var app = builder.Build();
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<ShopDb>();
db.Database.EnsureCreated();
if (!db.Customers.Any())
{
for (var i = 0; i < 200; i++)
{
var customer = new Customer { Name = $"Customer {i}" };
customer.Orders.Add(new Order { Total = i * 10m });
db.Customers.Add(customer);
}
db.SaveChanges();
}
}
// N+1: one query for orders, then one query per order for its customer
app.MapGet("/orders/slow", async (ShopDb db) =>
{
var orders = await db.Orders.ToListAsync();
var result = new List<object>();
foreach (var order in orders)
{
var customer = await db.Customers.SingleAsync(c => c.Id == order.CustomerId);
result.Add(new { order.Id, order.Total, Customer = customer.Name });
}
return result;
});
// One query with a projection
app.MapGet("/orders/fast", async (ShopDb db) =>
await db.Orders
.Select(o => new { o.Id, o.Total, Customer = o.Customer.Name })
.ToListAsync());
// Sync-over-async: blocks a thread pool thread for each request
app.MapGet("/report/blocking", () =>
{
Task.Delay(200).Wait();
return Results.Ok("done");
});
app.Run();
public sealed class ShopDb(DbContextOptions<ShopDb> options) : DbContext(options)
{
public DbSet<Customer> Customers => Set<Customer>();
public DbSet<Order> Orders => Set<Order>();
}
public sealed class Customer
{
public int Id { get; set; }
public required string Name { get; set; }
public List<Order> Orders { get; } = [];
}
public sealed class Order
{
public int Id { get; set; }
public decimal Total { get; set; }
public int CustomerId { get; set; }
public Customer Customer { get; set; } = null!;
}
CLI diagnostics against the running process:
dotnet tool install --global dotnet-counters
dotnet tool install --global dotnet-trace
dotnet-counters monitor -p 12345 --counters System.Runtime,Microsoft.AspNetCore.Hosting
dotnet-trace collect -p 12345 --duration 00:00:00:30 --format Speedscope
Mistake: profiling a Debug build or with the debugger attached.
Debug code isn't optimised and the debugger changes timings, so you optimise code the JIT would have handled and miss the real hot path.
Instead: profile a Release build with production-like data, from the Performance Profiler (Alt+F2) or dotnet-trace, without the debugger.
Mistake: guessing instead of measuring. Rewriting the layer you suspect often changes nothing, because the time is somewhere else. Instead: start from APM data: which endpoint, which percentile, which dependencies. Then use a profiler to find the code.
Mistake: looking only at CPU. A request waiting on SQL, HTTP, locks or the thread pool shows little CPU, so CPU Usage looks clean while users wait. Instead: check dependency timings, the Database and .NET Async tools, and thread pool counters. Remember network latency: a 2 ms query can cost 20 ms across regions, so keep app and database in the same Azure region.
Mistake: optimising the average. Averages hide the slow tail that users and SLAs actually feel. Instead: track p95 and p99, and set targets on them.
Mistake: optimising latency while ignoring throughput. A change that speeds up one request but uses more threads or connections can halve requests per second under load. Instead: measure p95 latency and throughput under the same load before and after each change.
Mistake: setting unrealistic targets. "Every endpoint under 10 ms" spends effort where it doesn't matter. Instead: agree targets with the product owner from real usage and the work each endpoint does, for example p95 under 300 ms at expected peak load.
Mistake: raising ThreadPool.SetMinThreads to hide sync-over-async.
It masks the symptom, uses more memory and still fails at a higher load.
Instead: find the blocking call (.Result, .Wait(), synchronous I/O) and make it await.
Mistake: changing several things at once. You can't tell which change helped, and one may have made things worse. Instead: change one thing, re-run the same load, and compare with the baseline.
Mistake: running instrumentation profiling in production.
Its overhead can cause the slowdown you are investigating.
Instead: use dotnet-counters, short sampling traces with dotnet-trace, or the Application Insights Profiler.
Mistake: fixing it once and not guarding it. The next feature quietly adds another query in a loop. Instead: add a load test with a p95 threshold to the pipeline, alerts on dependency calls per request, and a BenchmarkDotNet benchmark for hot library code.
Mistake: leaving trace files with personal data on shared drives. Traces can contain SQL parameters, URLs and payloads. Instead: store them with restricted access and delete them after the investigation.
Performance work is a measurement discipline. A senior engineer starts from a user-facing number and a target, picks the cheapest tool that can locate the problem (APM, then counters, then a profiler), and stops when the target is met, because every optimisation has a maintenance cost. They know which tool answers which question: sampling for CPU, async and database views for waiting, allocation tracking for GC pressure.
They set targets with the product owner from real usage (for example p95 under 300 ms at the expected peak requests per second), and treat performance as continuous rather than a one-off fix: dashboards on p95 and throughput per endpoint, alerts on dependency call counts and latency, load tests in the release pipeline with thresholds, BenchmarkDotNet for library hot paths, and a re-baseline after every .NET or infrastructure upgrade. That way the next feature cannot silently undo the work.
When I can't attach a debugger, I take a dump: a snapshot of the process's memory, threads and stacks that I analyse offline. For a hang or a leak, dotnet-dump collect -p <pid> does it in seconds. For crashes, I enable automatic crash dumps with the DOTNET_DbgEnableMiniDump environment variables. On App Service I use Diagnose and solve problems, and in Kubernetes dotnet-monitor. Then I analyse with dotnet-dump analyze: clrstack -all and syncblk for hangs, dumpheap -stat and gcroot for leaks, pe for exceptions. Or I open the dump in Visual Studio and use the Threads and Parallel Stacks windows. Dumps contain customer data, so they're handled as sensitive.
When it hangs, I press Break All so every thread stops where it is. In the Threads window I look for threads sitting in Monitor.Enter, Lock.Enter or Task.Wait, and switch to each one to read its call stack. Parallel Stacks shows all thread stacks merged into one graph, so two threads each holding a lock and waiting for the other's lock stand out immediately. That's the classic opposite-order deadlock. In production I can't attach, so I take a dump with dotnet-dump and run clrstack -all and syncblk. The fix is a consistent lock order, smaller locks, or removing sync-over-async.
How to tell it (STAR)
**Situation:** After a release, our [orders search] p95 went to 2.4 seconds and customer service staff were complaining. **Task:** I owned getting it back under our 300 ms target. **Action:** Application Insights showed about 180 SQL calls per request; I reproduced it in Release and the profiler's Database tool confirmed an N+1 query. I replaced it with one projected query, added a covering index, and re-measured against the baseline. **Result:** p95 fell to [220 ms], and I added a load test and an alert so it would not regress.
Last updated 1 Nov 2026.