📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

An API endpoint is slow. How do you use the Visual Studio Performance Profiler, dotnet-counters and dotnet-trace to find and fix the cause?

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.

Senior RealWorldScenario Advanced application-insights dotnet-counters dotnet-trace performance profiler

🎯 Key points

  • Measure first: find the slow endpoint, its p95/p99 latency and its throughput in Application Insights or logs before profiling
  • Break latency down: CPU in your code, waiting on dependencies, and network latency to SQL, HTTP services and storage
  • Profile a Release build without the debugger attached; the Diagnostic Tools window while debugging is only indicative
  • Visual Studio Performance Profiler (Alt+F2) tools: CPU Usage, .NET Object Allocation Tracking, Memory Usage, Database, .NET Async, .NET Counters, Instrumentation
  • Read hot paths and flame graphs; distinguish CPU-bound work from waiting (I/O, locks, thread pool)
  • dotnet-counters shows live runtime and ASP.NET Core metrics; dotnet-trace collects CPU samples and events from a running process
  • Common causes: N+1 queries, sync-over-async, excessive allocations, missing caching, chatty HTTP calls
  • Set a realistic target, fix one thing, re-measure against a baseline, and keep monitoring after release

⏱ 30-second answer

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.

🗣 Full answer (senior)

How I would answer this in an interview

"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-counters shows the thread pool queue and GC live and dotnet-trace captures 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."


1. Confirm and quantify

  • In Application Insights (or your APM), look at the endpoint's p95/p99 latency, not the average, and the dependency breakdown: how much is SQL, HTTP or Azure Storage. End-to-end transaction views show one slow request's timeline.
  • Agree a target (for example p95 under 300 ms) and record the current baseline.

2. Reproduce

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.

3. Profile locally with Visual Studio

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.

4. In test or production: CLI tools

  • 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.
  • In Azure, the Application Insights Profiler can capture traces from App Service on demand.

5. Fix, re-measure, guard

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.

Production example

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%.

🔍 What the interviewer is checking

The interviewer is assessing whether you approach performance scientifically and know the .NET tooling end to end.

Green flags

  • Starts with measurement and percentiles, sets a target and baseline.
  • Uses the right profiler tool for CPU, waiting, database and allocations.
  • Knows dotnet-counters and dotnet-trace for environments without Visual Studio.
  • Names common causes and verifies the fix.

Red flags

  • Jumps straight to rewriting code or adding caching.
  • Profiles Debug builds.
  • Cannot tell CPU-bound from I/O-bound slowness.

📚 Learn in depth

1. The method: measure, locate, fix, verify

 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.

2. Where the time goes in one request

 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

3. Sampling versus instrumentation

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.

4. Reading a flame graph

 |------------------- Kestrel request processing ------------------|
 |---------- OrdersEndpoint.Search ----------|--- JSON serialise ---|
 |-- EF Core query --|--- Map to DTO (x180) ---|
                     |-- string.Format --|
  • Each bar is a frame; the bars above it are the methods it called.
  • Width is the share of samples, so wide bars are where time goes.
  • Look for wide bars that you own. A wide string.Format under your mapper is actionable; a wide Kestrel frame on its own is not.
  • Repeated narrow towers of the same method usually mean it is called many times (an N+1 pattern or a loop).

5. The Visual Studio Performance Profiler tools

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.

6. dotnet-counters: the first look in any environment

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.

7. dotnet-trace: CPU samples and events

dotnet-trace collect -p <pid> --duration 00:00:00:30 --format Speedscope
  • The default collection includes CPU samples and common runtime events.
  • .nettrace files open in Visual Studio or PerfView; --format Speedscope gives a file for speedscope.app.
  • Keep captures short (30 to 60 seconds) under real load.
  • In Azure App Service, the Application Insights Profiler captures traces on demand or on triggers. In Kubernetes, dotnet-monitor can collect traces through an HTTP API.

8. The thread pool signal in detail

 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.

9. The usual causes and fixes

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

10. Latency and throughput together

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.

11. Security

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.

12. .NET 11 awareness

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.

13. Try it yourself

  1. Run the code example's API in Release and call /orders/slow and /orders/fast in a loop.
  2. Profile with CPU Usage and Database, and compare the number of queries per request.
  3. Run dotnet-counters monitor while sending concurrent requests to /report/blocking and watch the thread pool queue length and thread count.
  4. Change it to await Task.Delay(200) and repeat.
  5. Capture dotnet-trace for 30 seconds under load and open it in speedscope to find the widest frames you own.
  6. Record p95 and requests per second before and after each change.

💻 Code

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

⚠ Common mistakes

  • 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.

🏛 Senior insight: trade-offs

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.

🔗 Follow-ups the interviewer may ask

  1. When: The candidate sees memory growth or requests that never complete. Why: Some performance problems are really hangs or leaks, which need dumps.
    How to answer

    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.

  2. When: The candidate sees requests stall with low CPU. Why: Separates a hang from slowness and checks deadlock diagnosis.
    How to answer

    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.

🛠 Real-world problems: how you handle them

Orders search p95 of 2.4 seconds caused by an N+1 query
Situation
An ASP.NET Core orders search endpoint used by customer service staff in Australia had a p95 of 2.4 s after a feature added customer names to results. The target was 300 ms.
Signals
Application Insights showed the endpoint's p95 climbing after the release, around 180 SQL dependency calls per request, and DTU spikes on the Azure SQL database during business hours.
Investigation
Reproduced with production-like data in Release, ran the Visual Studio Performance Profiler with Database and CPU Usage tools, and saw one query for orders followed by one query per order for the customer. Allocation tracking showed full entities materialised for every row.
Resolution
Replaced the loop with a single projection selecting only the needed columns, added a covering index on the search columns, and made the query `AsNoTracking`. p95 dropped to 220 ms and allocations per request fell by about 85%.
Prevention
Added an Application Insights alert on dependency calls per request for key endpoints, a k6 load test in the release pipeline with a p95 threshold, and a code review checklist item for queries inside loops.

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.

↩ Usually asked after

More in Debugging Tools

Last updated 1 Nov 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me