TL;DR — We took Mattrx (multi-tenant marketing analytics SaaS, Angular 19 + .NET 9, 6 Azure App Service instances, 110k MAU) from 1,200 RPS → 4,500 RPS per instance in a 2-week async audit. Same code. Same SQL. Same Azure tier. The fix was 10 async/await mistakes that the compiler doesn't catch.
👉 Full deep-dive (with before/after code for all 10, the symptom in Application Insights, and the exact
dotnet-countersreading that exposes each one): https://prepstack.co.in/blog/async-await-mistakes-that-kill-aspnet-core-api-performance-guide
The mental model first
async/await doesn't make code faster. It makes threads available.
ASP.NET Core's threadpool has ~30 worker threads by default. If you block one (with .Result, .Wait(), lock across await), the 31st request queues. At 200 concurrent requests, you're starving. CPU sits at 30% while latency climbs to 4 seconds.
Every mistake below is one of:
- Blocking a thread instead of releasing it.
- Doing sequential I/O when parallel I/O is possible.
- Allocating a state machine you don't need.
- Letting work leak outside its scope.
The 10 mistakes (ranked by what they cost us)
| # | Mistake | Symptom | Mattrx win |
|---|---|---|---|
| 1 |
.Result / .Wait() (sync-over-async) |
Thread-pool starvation, random deadlocks | 1,200 → 4,500 RPS |
| 2 | async void |
Unhandled exceptions crash the process | 3 weekly crashes → 0 |
| 3 | Sequential awaits where parallel works |
/api/import 8.2s when 1.4s was possible |
p95 8.2s → 1.4s |
| 4 |
Task.Run on a request thread |
Wastes a slot to "offload" already-async work | 12% CPU at peak → 4% |
| 5 | Missing CancellationToken
|
Orphaned requests run after client gave up | 240 weekly TaskCanceled → 6 |
| 6 |
async keyword on methods that don't await
|
Pure state-machine allocation | -40% allocations on hot paths |
| 7 | Fire-and-forget without error capture | Silent failures, data corruption later | 18 weekly "phantom errors" → 0 |
| 8 | Awaiting in tight loops instead of batching | N round-trips when 1 would do |
/api/webhook/replay 9.4s → 720ms |
| 9 | Holding lock across await
|
Doesn't span awaits → race conditions | 4 prod incidents → 0 |
| 10 |
ValueTask<T> misuse (or absence in hot paths) |
UB if awaited twice; missed alloc savings | -28% allocations in hot path |
Mistake #1 in detail (the one that delivered ~70% of the win)
The pattern that compiles cleanly and starves your threadpool:
// ❌ Inside a synchronous method that "needs" an async result
public Campaign LoadCampaign(Guid id)
{
return _db.Campaigns.FirstAsync(c => c.Id == id).Result;
}
// ❌ The "I'll just wait here" anti-pattern
public IActionResult Get(Guid id)
{
var task = _campaigns.LoadAsync(id);
task.Wait();
return Ok(task.Result);
}
What this does at 100 concurrent requests:
Thread 1: blocked on DB roundtrip (200ms)
Thread 2: blocked on DB roundtrip (200ms)
...
Thread 28: blocked on DB roundtrip (200ms)
Thread 29: not yet allocated by ThreadPool
Request 30+ — queued. Latency climbs from 200ms → 4 seconds.
The fix is the boring one: async all the way.
// ✅ The whole chain is async
public async Task<Campaign> LoadCampaignAsync(Guid id, CancellationToken ct)
{
return await _db.Campaigns.FirstAsync(c => c.Id == id, ct);
}
public async Task<IActionResult> Get(Guid id, CancellationToken ct)
{
return Ok(await _campaigns.LoadAsync(id, ct));
}
We had 11 sync-over-async call sites in the codebase. Fixing all 11 was ~70% of the throughput win.
The detection arsenal
The first hour of any async investigation is the same five commands:
# Step 1 — confirm it's an async/threadpool issue
dotnet-counters monitor -p <pid> System.Runtime
# Watch:
# threadpool-queue-length > 0 sustained → starvation
# threadpool-thread-count climbing > 50 → reactive growth (.Result somewhere)
# monitor-lock-contention-count > 1k/sec → lock across await
# Step 2 — find the BLOCKER
dotnet-stack report -p <pid>
# Look for "Task.GetAwaiter().GetResult()" in stacks
# Step 3 — find the LEAK
# In Application Insights, search TaskCanceledException
# >100/min = orphaned requests (Mistake #5)
# Step 4 — find sequential awaits that should be parallel
# In App Insights end-to-end view, find handlers with >3 sequential awaits
# Step 5 — find async void / fire-and-forget
grep -nE '(public|private|internal)\s+async\s+void' src/
grep -nE '_ = \w+Async|Task\.Run\(' src/
The mental checklist (before merging any non-trivial async code)
- [ ] No
.Result,.Wait(), or.GetAwaiter().GetResult()anywhere in the call chain? - [ ] Every
asyncmethod returnsTaskorTask<T>— neverasync void? - [ ] Are sequential
awaits actually dependent on each other? If not,Task.WhenAll? - [ ] No
Task.Runwrapping already-async work? - [ ] Every public async method has a
CancellationTokenparameter, propagated to every inner await? - [ ] Methods with no
awaitdon't have theasynckeyword? - [ ] All fire-and-forget paths log exceptions OR go through a queue?
- [ ] Tight loops with awaits use
Parallel.ForEachAsyncorSemaphoreSlim-bounded concurrency? - [ ] No
lock/Monitor.Enterpatterns spanningawait? - [ ]
ValueTask<T>only on hot paths where the await-once contract is documented?
If any answer is "I'm not sure" — dotnet-counters it before shipping.
The full writeup with before/after code for all 10 mistakes, the symptoms in Application Insights, and the production metrics from the Mattrx audit:
👉 https://prepstack.co.in/blog/async-await-mistakes-that-kill-aspnet-core-api-performance-guide
If this saved you an outage, a ❤️ or 🦄 helps it reach more .NET devs.
What's the worst async footgun you've found in code review?
Top comments (0)