DEV Community

kirandeepjassal-crypto
kirandeepjassal-crypto

Posted on • Originally published at prepstack.co.in

10 Async/Await Mistakes That Kill ASP.NET Core API Performance (2026 Production Audit)

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-counters reading 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:

  1. Blocking a thread instead of releasing it.
  2. Doing sequential I/O when parallel I/O is possible.
  3. Allocating a state machine you don't need.
  4. 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);
}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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));
}
Enter fullscreen mode Exit fullscreen mode

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/
Enter fullscreen mode Exit fullscreen mode

The mental checklist (before merging any non-trivial async code)

  • [ ] No .Result, .Wait(), or .GetAwaiter().GetResult() anywhere in the call chain?
  • [ ] Every async method returns Task or Task<T> — never async void?
  • [ ] Are sequential awaits actually dependent on each other? If not, Task.WhenAll?
  • [ ] No Task.Run wrapping already-async work?
  • [ ] Every public async method has a CancellationToken parameter, propagated to every inner await?
  • [ ] Methods with no await don't have the async keyword?
  • [ ] All fire-and-forget paths log exceptions OR go through a queue?
  • [ ] Tight loops with awaits use Parallel.ForEachAsync or SemaphoreSlim-bounded concurrency?
  • [ ] No lock / Monitor.Enter patterns spanning await?
  • [ ] 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)