Profiling a Slow ASP.NET Core Endpoint: From 2s to 80ms
A real production debugging story: the systematic path from "the dashboard is slow" to a 25x speedup. Tools, traces, and the four bottlenecks we found.
- Author
- Randhir Jassal
- Published
- Reading time
- 11 min read
The problem
GET /api/dashboard/summary was averaging 2.1 seconds in production. Customers complained the dashboard "took forever to load". p95 was 4.8 seconds.
Step 1 — distinguish wall-clock from server time
Browser network panel showed 2.1s end-to-end. Server-side trace showed 1.9s. So 200ms was network + render, the rest was the server.
Step 2 — enable OpenTelemetry tracing
Wire OTel to capture every span automatically. Five minutes of setup, weeks of value.
builder.Services.AddOpenTelemetry()
.WithTracing(t => t
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddEntityFrameworkCoreInstrumentation()
.AddOtlpExporter());
Looking at one trace in Honeycomb:
GET /api/dashboard/summary 1,940 ms
├── EF Core: SELECT orders WHERE customer_id=? 320 ms
├── EF Core: SELECT customers WHERE id=? 40 ms × 35 calls
├── HTTP GET https://billing/api/v1/quota 580 ms
└── application logic 60 ms
Three smoking guns visible at a glance.
Bottleneck 1: N+1 query (1.4s saved)
The 35-call sawtooth was lazy-loaded Customer navigations inside a foreach. Classic.
Before:
var orders = await db.Orders
.Where(o => o.PlacedAt >= since)
.ToListAsync();
foreach (var o in orders)
summary.Add(new Row(o.Id, o.Customer.Name));
After (projection):
var summary = await db.Orders
.Where(o => o.PlacedAt >= since)
.Select(o => new Row(o.Id, o.Customer.Name))
.ToListAsync();
One JOIN, one round-trip. Trace went from 35 calls × 40ms to 1 call × 75ms.
Bottleneck 2: missing index (200ms saved)
The single remaining EF query was scanning 4M rows on orders.placed_at. The column had no index. EXPLAIN ANALYZE confirmed a sequential scan.
CREATE INDEX CONCURRENTLY idx_orders_placed_at
ON orders (placed_at DESC)
WHERE placed_at >= '2026-01-01';
Partial index because old data was already cold. Index size: 12 MB. Query: 75ms → 4ms.
Bottleneck 3: synchronous HTTP call (520ms saved)
The 580ms billing/api/v1/quota call ran sequentially after the EF queries. It didn't depend on the EF results — it could run in parallel.
var ordersTask = LoadOrdersAsync(since, ct);
var quotaTask = billing.GetQuotaAsync(customerId, ct);
await Task.WhenAll(ordersTask, quotaTask);
Saved the full duration of the shorter task. The remaining billing call was still slow but no longer blocking.
Bottleneck 4: caching what doesn't change (60ms saved)
The quota call's result was stable for 5 minutes. Cached with IDistributedCache backed by Redis:
public async Task<Quota> GetQuotaAsync(Guid customerId, CancellationToken ct) {
var key = $"quota:{customerId}";
var cached = await cache.GetStringAsync(key, ct);
if (cached is not null) return JsonSerializer.Deserialize<Quota>(cached)!;
var quota = await api.GetQuotaAsync(customerId, ct);
await cache.SetStringAsync(key,
JsonSerializer.Serialize(quota),
new() { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5) },
ct);
return quota;
}
Cache hit rate stabilised at 94%. Average call dropped from 580ms to 35ms.
Final numbers
| Stage | Before | After |
|---|---|---|
| EF query loop | 1,400ms | 4ms |
| Billing HTTP | 580ms | 35ms (cached) |
| Application | 60ms | 60ms |
| Network/render | 200ms | 200ms |
| Total p50 | 2,100ms | ~80ms |
26x improvement. No new infrastructure. The same code can carry 25x more load.
The non-technical lesson
Every one of these bottlenecks was visible in the first trace I opened. The hard part wasn't the fix; it was sitting down for 30 minutes to actually look. Make tracing the default in production. The investment pays back the first time you reach for it.
Get the next issue
A short, curated email with the newest posts and questions.