10 Async/Await Mistakes That Kill ASP.NET Core API Performance in 2026 — Real Causes, Before/After Code, Production Metrics
10 async/await mistakes that wreck API throughput — .Result, async void, sequential awaits, lock across await. Mattrx 1,200 → 4,500 RPS in 2 weeks.
- Author
- Randhir Jassal
- Published
- Reading time
- 26 min read
- Views
- 461 views
10 Async/Await Mistakes That Kill ASP.NET Core API Performance in 2026 — Real Causes, Before/After Code, Production Metrics
Async/await looks like syntactic sugar. It isn't. It's a state machine the compiler generates, a Task allocation, a continuation scheduled against the thread pool, and a synchronization context that ASP.NET Core has opinions about. Use it correctly and your API serves 5,000 RPS on 22 threads. Use it wrong and the same API hits thread-pool starvation at 1,200 RPS, deadlocks at random, leaks
TaskCanceledExceptions, and OOMs from state-machine allocations under load.The mistakes that cause this don't show up as compiler errors. They compile. They pass tests. They work in development with a single user. They fail at the 3,000 RPS, 5-minute mark in production, exactly when someone is sleeping and a CTO is awake.
This is the catalog of the 10 async/await mistakes we actually found and fixed at Mattrx — a multi-tenant marketing analytics SaaS (Angular 19 + .NET 9, 6 Azure App Service instances, 110k MAU). Each mistake has the before code that compiles cleanly, the after code that works under load, a diagram for the gnarly ones, the symptom you'd see in Application Insights, and the exact
dotnet-countersreading that exposes it. Plus the aggregate before/after: thread-pool starvation incidents 4/week → 0, /api/import p95 8.2s → 1.4s, deadlocks 3/month → 0.
TL;DR
The 10 async/await mistakes, ranked by what they actually cost in production:
| # | Mistake | Symptom in prod | Mattrx impact |
|---|---|---|---|
| 1 | .Result / .Wait() (sync-over-async) | Thread-pool starvation, deadlocks at scale | Throughput 1,200 → 4,500 RPS |
| 2 | async void | Unhandled exceptions crash the process; no awaiter | 3 weekly Sentry crash reports → 0 |
| 3 | Sequential awaits where parallel works | /api/import took 8.2s when 1.4s was possible | p95 8.2s → 1.4s |
| 4 | Task.Run on a request thread | Wastes a thread pool slot to "offload" already-async work | 12% CPU at peak → 4% |
| 5 | Missing CancellationToken | Orphaned requests run to completion after client gave up | 240 weekly TaskCanceled exceptions → 6 |
| 6 | async keyword on synchronous methods | Allocates a state machine for no reason | -40% allocations on hot paths |
| 7 | Fire-and-forget without error capture (_ = SomeAsync()) | Failures vanish silently; you only see corruption later | 18 weekly "phantom errors" → 0 |
| 8 | Awaiting in tight loops instead of batching | N round-trips when 1 would do (related to N+1) | /api/webhook/replay 9.4s → 720ms |
| 9 | Holding lock across await | lock doesn't span awaits → race conditions | 4 prod incidents → 0 |
| 10 | ValueTask<T> misuse (or absence in hot paths) | Either you await it twice (UB) or miss free allocation savings | -28% allocations in hot serializer |
Aggregate Mattrx wins after the 2-week async audit:
- Thread-pool starvation incidents: 4/week → 0 (90 days)
- Throughput per instance: 1,200 RPS → 4,500 RPS (3.75×)
- Worker threads at peak load: 110 → 22 (-80%)
- Deadlock reports / month: 3 → 0
TaskCanceledExceptionfrom orphaned requests / week: 240 → 6- Async-state-machine allocations on
/api/dashboard/kpis: -40% - GC % time on hot endpoints: 9% → 3%
/api/importp95: 8.2s → 1.4s (-83%)/api/webhook/replayp95: 9.4s → 720ms (-92%)- Sentry "phantom failure" reports: 18/week → 0
Two weeks of work. No new infrastructure. No rewrite. The mistakes were all 10 boring patterns listed above.
1. The mental model — what async actually does
asyncdoes not run anything in parallel.asyncdoes not "make it faster."asyncreleases the current thread while waiting for I/O — so that same thread can serve another request.
Read that twice. Almost every async mistake comes from misunderstanding it.
WITHOUT ASYNC (blocking I/O):
Request 1 ─► [thread A held]: query DB ─ wait 200ms ─ format ─ respond
↑ thread A is BLOCKED, unavailable for anyone else
Request 2 ─► [waits for a thread]
WITH ASYNC (non-blocking I/O):
Request 1 ─► [thread A]: start DB query ─ released ─ ...
│
▼
[thread B, 200ms later]: continue ─ format ─ respond
Request 2 ─► [thread A] becomes free in microseconds ─ serve request 2
The win isn't "fewer milliseconds per request." The win is threads stay available. ASP.NET Core's thread pool has ~30 worker threads by default; if you block them, the 31st request queues, the 32nd queues, and at 200 concurrent requests you're starving.
Every mistake in this guide is one of:
- Blocking a thread instead of releasing it (
.Result,.Wait(),lockacross await). - Doing sequential I/O when parallel I/O is possible.
- Allocating a state machine you don't need.
- Leaking work that outlives its scope.
The detection arsenal
| Tool | Shows | When to reach for it |
|---|---|---|
dotnet-counters monitor -p <pid> System.Runtime | Thread-pool queue length, lock contention, allocation rate | First diagnostic; runs in prod |
dotnet-counters monitor System.Threading.Tasks.TplEventSource | Task counts, scheduler queue depth | Async-specific deep-dive |
| Application Insights end-to-end transaction | Where the request thread blocked vs awaited | Production trace |
| PerfView "Async Causality" + "Lock Contention" | Visual of every async chain + lock waits | The hard cases |
| dotnet-stack report | Live thread stacks → see who's blocked on what | When everything is hung |
Bad async smell test — open dotnet-counters and look at:
threadpool-queue-length: > 0 for sustained periods → starvation
threadpool-thread-count: climbing > ~50 → over-provisioning
monitor-lock-contention-count: > 1k/sec → lock-across-await
exception-count: > 50/sec at low RPS → TaskCanceled flood
2. Mattrx — the running example
Mattrx serves ~3,200 requests/sec at peak across 6 ASP.NET Core 9 instances. The hot paths:
/api/dashboard/kpis— 80k req/day, low latency, OnPush dashboard/api/campaigns/{id}/archive— write endpoint, fires SignalR + email + audit/api/import— bulk-imports CSVs (multi-second by nature)/api/webhook/replay— replays partner webhooks (multi-second too)/api/inbox/stream— SignalR hub
The 10 mistakes below are in rough order of how much they cost us. Fix the top three and you'll claim most of the win.
3. Mistake #1 — .Result / .Wait() (sync-over-async)
The bug: Calling .Result, .Wait(), or .GetAwaiter().GetResult() on a Task blocks the current thread until the task completes. In ASP.NET Core, the current thread is a thread-pool thread. Block enough of them and the pool runs out. New requests queue. Eventually they time out.
Before — the classic sync-over-async
// ❌ Inside a synchronous method that "needs" an async result
public Campaign LoadCampaign(Guid id)
{
// Blocks the calling thread for the entire DB roundtrip
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(); // ← blocks thread pool thread
return Ok(task.Result);
}
// ❌ "GetAwaiter().GetResult()" — same problem, slightly less hated
public Campaign LoadSync(Guid id)
=> _campaigns.LoadAsync(id).GetAwaiter().GetResult();
What this does to your thread pool
At 100 concurrent requests, all calling `.Result`:
• 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.SetMinThreads
• Request 30+ ── queued. Latency climbs from 200ms → 4 seconds.
The thread pool injects new threads slowly (one every ~500ms by default). The request queue grows faster than the pool grows. You're "scaling" by allocating threads, which uses up memory and CPU, instead of releasing them like async was designed for.
Diagnostic
dotnet-counters monitor -p <pid> System.Runtime
threadpool-queue-length: 24, 38, 67, 110, 240, ... ← starvation signal
threadpool-thread-count: 32, 44, 58, 72, 96, ... ← reactive growth
In Application Insights: tail-latency p99 spikes to 4–10× p50 under load, even though CPU is < 30%.
After — propagate async all the way up
// ✅ The whole chain is async
public async Task<Campaign> LoadCampaignAsync(Guid id, CancellationToken ct)
{
return await _db.Campaigns.FirstAsync(c => c.Id == id, ct);
}
// ✅ Controller stays async too
public async Task<IActionResult> Get(Guid id, CancellationToken ct)
{
return Ok(await _campaigns.LoadAsync(id, ct));
}
The rule: "async all the way." If a method calls an async method, it should be async itself. There is exactly one excuse to use .Result: writing a console app's Main in old C#. ASP.NET Core 6+ supports async Main; you don't need it.
The "library that exposes a sync API" problem
Sometimes you can't change the caller. In that case the least bad option is Task.Run:
// Acceptable bridge (NOT IDEAL): you control the threadpool blast
public Campaign LoadCampaign(Guid id) =>
Task.Run(async () => await LoadCampaignAsync(id, default)).GetAwaiter().GetResult();
This trades a synchronous block of the calling thread for a different threadpool thread that blocks. You haven't fixed the problem — you've moved it. Use only when you literally can't change the API boundary.
Mattrx metric
11 sync-over-async call sites found via grep (.Result\b, .Wait(), .GetAwaiter().GetResult()). Fixed all 11. Throughput per instance: 1,200 → 4,500 RPS. Thread-pool starvation incidents: 4/week → 0.
4. Mistake #2 — async void
The bug: async void methods can't be awaited and their exceptions can't be caught by the caller. They crash the process. They make event handlers look fine and tests look green.
Before — the timer that crashed prod
// ❌ async void in a hosted service
public class TimedReportRefresher : BackgroundService
{
protected override Task ExecuteAsync(CancellationToken ct)
{
var timer = new Timer(_ => RefreshReportsAsync(ct), null, 0, 30_000);
return Task.Delay(Timeout.Infinite, ct);
}
// ❌ async void — exceptions here CRASH THE PROCESS
private async void RefreshReportsAsync(CancellationToken ct)
{
var reports = await _db.Reports.ToListAsync(ct);
foreach (var r in reports) await RefreshAsync(r, ct);
}
}
If RefreshAsync throws, the exception propagates to the synchronization context, which is null in a Timer callback, which crashes the process. You see it in Sentry as AppDomain.UnhandledException → process restart. Mattrx had 3 of these per month.
Why async void exists at all
For exactly one reason: WinForms / WPF event handlers. The framework signature requires void return, so async event handlers must be async void. Outside that scenario, never use it.
After — return Task and observe
// ✅ Returns Task; caller can observe failures
public class TimedReportRefresher(IServiceScopeFactory scopeFactory, ILogger<TimedReportRefresher> log)
: BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
using var periodic = new PeriodicTimer(TimeSpan.FromSeconds(30));
while (await periodic.WaitForNextTickAsync(ct))
{
try
{
await RefreshReportsAsync(ct);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
catch (Exception ex)
{
log.LogError(ex, "RefreshReports failed; will retry next tick.");
}
}
}
private async Task RefreshReportsAsync(CancellationToken ct)
{
using var scope = scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var reports = await db.Reports.ToListAsync(ct);
foreach (var r in reports) await RefreshAsync(r, ct);
}
}
Two improvements:
Tasknotvoid— caller can await and observe failure.PeriodicTimernotSystem.Threading.Timer— async-aware, respects cancellation, runs sequentially (won't fire callback while previous one is still running).
The async lambda gotcha
// ❌ This compiles. It's async void in disguise.
button.Click += async (s, e) =>
{
await DoWorkAsync();
};
// ✅ For framework events that need a Task return, use a helper
button.Click += (s, e) => _ = HandleAsync(s, e); // explicit fire-and-forget (see Mistake #7)
Anywhere you pass an async lambda to an Action, you've created async void. Watch for this with EventHandler, Action<T>, LINQ Where/Select, and Parallel.ForEach.
Mattrx metric
Five async voids found, all in background timers / event subscriptions. Process crashes from unhandled exceptions: 3/month → 0.
5. Mistake #3 — Sequential awaits where parallel works
The bug: Two awaits in a row, each on independent work. The second waits for the first to finish even though it didn't need to.
Before — the slow import
// ❌ Six awaits, sequential — wall-clock = sum of all six
public async Task<ImportPreview> PreviewAsync(Guid jobId, CancellationToken ct)
{
var job = await _db.ImportJobs.FindAsync([jobId], ct); // ~80 ms
var settings = await _settings.GetAsync(job.TenantId, ct); // ~40 ms
var csv = await _blob.DownloadAsync(job.BlobUri, ct); // ~600 ms
var validators = await _validators.GetForKindAsync(job.Kind, ct); // ~30 ms
var fxRates = await _fx.GetCurrentRatesAsync(job.Currency, ct); // ~80 ms
var preview = await _parser.ParseAsync(csv, settings, validators, ct); // ~200 ms
return new ImportPreview(job, preview, fxRates);
}
// Total wall-clock: ~1,030 ms (worst case 8,200ms with cold caches)
The first 5 lines are independent — they don't read each other's results. Only the 6th line uses the previous results.
Diagnostic
A single MiniProfiler waterfall shows the issue immediately:
[ Preview ] ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 1,030 ms
├─ ImportJobs.Find ━━ 80 ms
├─ Settings.Get ━ 40 ms
├─ Blob.Download ━━━━━━━━━━━━━━━ 600 ms
├─ Validators.GetForKind ━ 30 ms
├─ Fx.GetCurrentRates ━━ 80 ms
└─ Parser.Parse ━━━ 200 ms
Notice: every step waits for the previous one. None of them needed to.
After — fan out with Task.WhenAll
// ✅ Five independent operations start in parallel; await once
public async Task<ImportPreview> PreviewAsync(Guid jobId, CancellationToken ct)
{
var job = await _db.ImportJobs.FindAsync([jobId], ct); // need job first to get TenantId, BlobUri, etc.
// These 5 are independent of each other
var settingsTask = _settings.GetAsync(job.TenantId, ct);
var csvTask = _blob.DownloadAsync(job.BlobUri, ct);
var validatorsTask = _validators.GetForKindAsync(job.Kind, ct);
var fxRatesTask = _fx.GetCurrentRatesAsync(job.Currency, ct);
await Task.WhenAll(settingsTask, csvTask, validatorsTask, fxRatesTask);
// The final step needs results of the previous 4
var preview = await _parser.ParseAsync(csvTask.Result, settingsTask.Result,
validatorsTask.Result, ct);
return new ImportPreview(job, preview, fxRatesTask.Result);
}
// Total wall-clock: ~max(40, 600, 30, 80) + 80 (job) + 200 (parse) = ~880 ms in dev,
// but the heavy case (blob 6s, fx cold 800ms) goes from 8.2s → 1.4s.
Diagram — sequential vs parallel
SEQUENTIAL (before):
Time →
0────80────120───720────────────750─────830─────────────1030 ms
[Job][Set ][Blob ─────────────────][Val ][Fx ][Parse ────────]
PARALLEL (after):
0────80───────────────────────────680──────────────────────880 ms
[Job]
[Set ──────]
[Blob ─────────────────────]
[Val ─────]
[Fx ──────────]
[Parse ─────────────────]
▲
Task.WhenAll completes
When parallel is wrong
- When B depends on A's result.
var u = await GetUser(); var orders = await GetOrders(u.Id);is correctly sequential. - When operations share state that isn't thread-safe (EF Core
DbContextis not thread-safe — never run two queries on the same context concurrently). - When you need ordering guarantees for side effects.
For EF Core specifically:
// ❌ Same DbContext, two concurrent queries — throws InvalidOperationException
var t1 = _db.Campaigns.ToListAsync(ct);
var t2 = _db.Events.ToListAsync(ct);
await Task.WhenAll(t1, t2); // ← BOOM
// ✅ Either use scoped factories OR await sequentially
await using var db1 = _factory.CreateDbContext();
await using var db2 = _factory.CreateDbContext();
var t1 = db1.Campaigns.ToListAsync(ct);
var t2 = db2.Events.ToListAsync(ct);
await Task.WhenAll(t1, t2);
For pure HTTP / cache reads / file I/O, Task.WhenAll is safe.
Mattrx metric
/api/import preview: p95 8.2s → 1.4s (-83%). The biggest single async fix in the audit.
6. Mistake #4 — Task.Run on an ASP.NET Core request thread
The bug: Someone wraps an async call in Task.Run "to make it non-blocking." This is wrong twice:
- The async call was already non-blocking.
Task.Runadds nothing. Task.Runmoves the work to a different threadpool thread, then awaits that — which means two threadpool slots are used instead of one.
Before — well-intentioned, wrong
// ❌ "Let's make this non-blocking" — but it already was
public async Task<IActionResult> GetReports(Guid tenantId, CancellationToken ct)
{
var reports = await Task.Run(async () =>
await _db.Reports.Where(r => r.TenantId == tenantId).ToListAsync(ct), ct);
return Ok(reports);
}
This consumes thread A to fire Task.Run, which schedules work on thread B, which awaits the DB. While B waits, A is also held. You've doubled the thread-pool cost of the request.
When Task.Run IS the right answer
Only when you have genuinely synchronous, CPU-bound work that would otherwise block the request thread:
// ✅ Image compression IS CPU-bound; offload to keep request thread free
public async Task<IActionResult> ProcessImage(byte[] data, CancellationToken ct)
{
var thumbnail = await Task.Run(() => GenerateThumbnail(data), ct); // CPU-heavy, sync
return Ok(thumbnail);
}
// ✅ CSV parsing of a 50 MB blob, no async parser available
var parsed = await Task.Run(() => ParseCsvSync(content), ct);
The rule:
| Work shape | Should you Task.Run it? |
|---|---|
| Async I/O (DB, HTTP, file with async API) | No — it's already non-blocking |
| Sync CPU work (compression, parsing, hashing) | Yes, if it's > ~50ms |
| Sync I/O (legacy library with no async API) | Last resort — see Mistake #1 |
| Tiny work (< 1ms) | No — overhead dominates |
Mattrx metric
Found 8 misplaced Task.Run in async handlers. Removing them: peak CPU 12% → 4% + thread-pool worker count: 110 → 22 at peak.
7. Mistake #5 — Missing CancellationToken
The bug: Long-running work that can't be cancelled keeps running after the client disconnected. Wasted CPU, wasted DB cycles, wasted memory holding partial results. At scale: 10%+ of your throughput is doing work nobody is waiting for.
Before
// ❌ No CancellationToken; server keeps working after client drops
public async Task<IActionResult> ListCampaigns()
{
var campaigns = await _db.Campaigns.ToListAsync(); // ← no ct
var enriched = await _enricher.EnrichAsync(campaigns); // ← no ct
return Ok(enriched);
}
A user closes the tab during a 4-second response. The server has no idea. Both queries run to completion. The response is built. JSON is serialized. The response writer fails with IOException: response stream closed. The user's CPU was spent on garbage.
After
// ✅ Propagate ct from the controller signature all the way down
public async Task<IActionResult> ListCampaigns(CancellationToken ct)
{
var campaigns = await _db.Campaigns.ToListAsync(ct);
var enriched = await _enricher.EnrichAsync(campaigns, ct);
return Ok(enriched);
}
ASP.NET Core injects CancellationToken ct into the controller action when the request is cancelled (client disconnect, timeout). EF Core, HttpClient, MediatR, StreamReader.ReadLineAsync, almost every modern async API accepts a CancellationToken. Pass it.
How a missing token bites you
Client: GET /api/campaigns ── connects ──► API
│
▼
[SQL: SELECT * FROM Campaigns] starts
Client: closes tab ◄────────── still running
│
▼ (4 seconds later)
SQL returns 50,000 rows
│
JSON serialize 50,000 rows (still running)
│
▼
ResponseStream.Write → IOException (client gone)
→ 4 seconds of API CPU + 4 seconds of DB CPU spent on a result nobody wanted.
The double cancellation pattern (when you need to cancel for your own reasons)
// ✅ Combine the request's CT with your own
public async Task<List<Result>> SearchAsync(string q, CancellationToken ct)
{
using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(ct, timeoutCts.Token);
return await _api.SearchAsync(q, linkedCts.Token);
}
This cancels if EITHER the request is cancelled OR the 5-second timeout fires.
Mattrx metric
10 endpoints + 18 service methods updated to propagate CancellationToken. TaskCanceledException rate from orphaned requests: 240/week → 6. CPU at peak: -4%. The bigger qualitative win: fewer support tickets about "the dashboard hung" because users could now cancel.
8. Mistake #6 — async keyword on methods that aren't actually async
The bug: Marking a method async always allocates a state machine, even if the method has no await. For a method called 100k times/sec, that's 100k unnecessary allocations.
Before
// ❌ async keyword for no reason — allocates a state machine
public async Task<Campaign> WrapCachedAsync(Guid id)
{
if (_cache.TryGetValue(id, out Campaign c))
return c;
return await LoadAsync(id);
}
// ❌ async with no await — pure waste
public async Task<int> AddAsync(int a, int b)
{
return a + b;
}
The compiler emits a state machine struct (<WrapCachedAsync>d__0), boxes it on the first await (if any), and pays the cost of capturing the synchronization context. For the hot-cache case (no await taken) it's wasted.
After — return the Task directly
// ✅ Cache-hit path is synchronous; only allocate state machine if we fall through
public Task<Campaign> WrapCachedAsync(Guid id)
{
if (_cache.TryGetValue(id, out Campaign c))
return Task.FromResult(c);
return LoadAsync(id);
}
// ✅ No async needed for a pure sync method
public Task<int> AddAsync(int a, int b)
=> Task.FromResult(a + b);
For ultra-hot paths, prefer ValueTask (Mistake #10) to avoid even the Task.FromResult allocation.
When to KEEP async on a method
- Multiple awaits with exception handling.
try { await A(); await B(); } catch (...) { ... }— theasyncmachinery handles exception unwrapping cleanly. Returning the Task directly forces you to manually.ContinueWithfor error handling. using/await usingblocks. You needasyncto dispose properly.- Anywhere the readability win outweighs the allocation cost.
The smell test
If your method body is return SomeOtherAsync(x); or return Task.FromResult(x); — drop the async.
If your method body has a single await at the end and nothing after — drop the async and return the Task directly.
// Pattern 1: single tail-await — return directly
public Task<Foo> GetAsync(Guid id) => _db.Foos.FindAsync(id).AsTask();
// Pattern 2: branching where one branch awaits — return Task in both branches
public Task<Foo> GetCachedAsync(Guid id) =>
_cache.TryGetValue(id, out Foo f) ? Task.FromResult(f) : GetAsync(id);
Mattrx metric
~40 methods refactored. Async-state-machine allocations on /api/dashboard/kpis: -40%. GC % time on hot endpoints: 9% → 3%. Not the headline win but compounds with everything else.
9. Mistake #7 — Fire-and-forget without error capture
The bug: _ = SomeAsync() or Task.Run(...) without await. Exceptions disappear. You learn there's a problem when data is silently inconsistent six hours later.
Before
// ❌ Fire-and-forget — exceptions silently swallowed
public IActionResult Archive(Guid id, [FromServices] AppDbContext db)
{
_ = SendAuditEmailAsync(id); // exception? Nobody knows.
_ = _hub.NotifyAllAsync(id); // exception? Crash signal lost.
return Accepted();
}
When SendAuditEmailAsync throws, the Task faulted state is observed by the GC's finalizer thread (if at all), which in modern .NET no longer crashes the process — but the error vanishes from logs. You will not know.
Hide-the-bug pattern: the empty try/catch
// ❌ "Catch and ignore" — still hides bugs
_ = Task.Run(async () =>
{
try { await SendAuditEmailAsync(id); }
catch { /* don't crash the process */ }
});
If you don't log the failure, you're choosing data corruption over a stack trace.
After — handoff to a queue, OR observe explicitly
Best: use a proper background work pattern (covered in detail in the Memory Leaks guide and the Architecture guide):
// ✅ Enqueue work; consumer hosted service runs it with retries + logging
public IActionResult Archive(Guid id, [FromServices] IBackgroundWorkQueue queue)
{
queue.Enqueue(new SendAuditEmail(id));
queue.Enqueue(new NotifySubscribers(id));
return Accepted();
}
If you must fire-and-forget on the request thread, observe the Task explicitly:
// ✅ Minimally acceptable — log every failure
public IActionResult Archive(Guid id)
{
_ = SafeFireAndForget(SendAuditEmailAsync(id), "SendAuditEmail");
_ = SafeFireAndForget(_hub.NotifyAllAsync(id), "HubNotifyAll");
return Accepted();
}
private async Task SafeFireAndForget(Task task, string operation)
{
try { await task; }
catch (Exception ex) { _logger.LogError(ex, "Fire-and-forget failed: {Op}", operation); }
}
This pattern still has the scope-capture problem from Mistake #4/Memory-Leaks Mistake #5, so prefer the queue pattern. But if you must — at least log.
Mattrx metric
23 fire-and-forget call sites. 18 migrated to the background-work queue, 5 wrapped in SafeFireAndForget. Phantom-failure Sentry reports: 18/week → 0. The qualitative win was bigger: when something does break, we see it now.
10. Mistake #8 — Awaiting in tight loops instead of batching
The bug: foreach (var x in xs) await ProcessAsync(x) — N sequential round-trips when one batch call or Task.WhenAll would do.
Before — sequential webhook replay
// ❌ 200 webhooks × 50ms each = 10 seconds
public async Task ReplayAsync(IReadOnlyList<WebhookEvent> events, CancellationToken ct)
{
foreach (var e in events)
{
await _partner.SendAsync(e, ct); // each: ~50ms HTTP round-trip
}
}
After — parallel with bounded concurrency
// ✅ Bounded parallelism — start 10 at a time, await all
public async Task ReplayAsync(IReadOnlyList<WebhookEvent> events, CancellationToken ct)
{
using var sem = new SemaphoreSlim(initialCount: 10, maxCount: 10);
var tasks = events.Select(async e =>
{
await sem.WaitAsync(ct);
try { await _partner.SendAsync(e, ct); }
finally { sem.Release(); }
});
await Task.WhenAll(tasks);
}
Why bounded:
- Pure
Task.WhenAll(events.Select(SendAsync))with 10,000 events would fire 10,000 HTTP requests simultaneously, exhausting the connection pool and triggering rate limits. SemaphoreSlim(10)caps concurrency at 10 in-flight requests. Tunes the trade-off between throughput and load on the downstream system.
Even better — Parallel.ForEachAsync (.NET 6+)
// ✅ Built-in bounded async parallelism
public Task ReplayAsync(IReadOnlyList<WebhookEvent> events, CancellationToken ct) =>
Parallel.ForEachAsync(events,
new ParallelOptions { MaxDegreeOfParallelism = 10, CancellationToken = ct },
async (e, token) => await _partner.SendAsync(e, token));
Parallel.ForEachAsync handles the semaphore, cancellation, and exception aggregation for you. Default to this for "do N async things with bounded parallelism."
When SEQUENTIAL is correct
- Order matters for the side effects. (E.g., "apply migrations in order.")
- Each iteration's result feeds the next. (Cumulative state.)
- Per-iteration concurrency cap is 1 by external constraint. (Single API key with strict rate limit.)
Mattrx metric
/api/webhook/replay (200-event batches): 9.4s → 720ms (-92%). /api/import/process (5000-row CSVs): 24s → 3.1s with MaxDegreeOfParallelism = 16 for the row-side enrichment.
11. Mistake #9 — Holding lock across await
The bug: lock (obj) { await ...; } is a compile error in C#. Devs work around it with Monitor.Enter + Monitor.Exit or boolean flags. The result: race conditions, deadlocks, or no actual exclusion.
Before — the dangerous workaround
// ❌ Looks like a lock but isn't — Monitor.Exit may run on a different thread
public async Task<int> IncrementCountAsync()
{
Monitor.Enter(_lock);
try
{
var current = await _db.Counters.FindAsync(_id); // ← thread suspended
current.Value++;
await _db.SaveChangesAsync(); // ← may resume on different thread
return current.Value;
}
finally
{
Monitor.Exit(_lock); // ← if we resumed on a different thread: SynchronizationLockException
}
}
Monitor.Enter / Monitor.Exit must run on the same thread. Async suspensions move execution between threads. This will throw at random.
The "fake lock" with a flag
// ❌ This doesn't lock anything — flag check + await isn't atomic
private bool _busy;
public async Task FooAsync()
{
if (_busy) return;
_busy = true; // ← two requests can both pass the check before either sets it
try { await DoAsync(); }
finally { _busy = false; }
}
Two requests can read _busy == false simultaneously, both proceed, both think they hold the "lock."
After — SemaphoreSlim is async-aware
// ✅ SemaphoreSlim works across awaits because it's async-aware
private readonly SemaphoreSlim _gate = new(initialCount: 1, maxCount: 1);
public async Task<int> IncrementCountAsync(CancellationToken ct)
{
await _gate.WaitAsync(ct);
try
{
var current = await _db.Counters.FindAsync([_id], ct);
current.Value++;
await _db.SaveChangesAsync(ct);
return current.Value;
}
finally
{
_gate.Release();
}
}
SemaphoreSlim(1, 1) behaves like a mutex that supports await. The release runs from finally, which is correct regardless of which thread we resume on.
Better still — don't need a lock if you can use DB-level concurrency
// ✅ DB-level optimistic concurrency — no lock needed in app code
public async Task<int> IncrementAsync(CancellationToken ct)
{
var rows = await _db.Database.ExecuteSqlAsync(
$"UPDATE Counters SET Value = Value + 1 WHERE Id = {_id}; SELECT @@ROWCOUNT", ct);
return await _db.Counters.Where(c => c.Id == _id)
.Select(c => c.Value)
.FirstAsync(ct);
}
The DB handles concurrency; no app-side lock at all. This is the right answer 90% of the time — DB locking is more correct, scales across instances (an app-level SemaphoreSlim only locks within one process), and is faster.
Mattrx metric
7 cross-await locking patterns identified. All migrated to either SemaphoreSlim (in-process exclusion) or DB-level optimistic concurrency. Race-condition incident reports: 4 → 0.
12. Mistake #10 — ValueTask<T> misuse (or absence in hot paths)
The bug: Two halves.
Half A: You await a ValueTask<T> more than once. Undefined behavior.
// ❌ DON'T DO THIS — ValueTask<T> may be reused by the producer
ValueTask<int> Get() => /* ... */;
async Task DoubleConsume()
{
var vt = Get();
var a = await vt;
var b = await vt; // ← UB. Throws or returns garbage.
}
Half B: You miss free allocation savings on hot paths. ValueTask<T> avoids the Task<T> heap allocation when the result is available synchronously (e.g., cache hit).
// ✅ Cache hit returns synchronously — no Task allocation
public ValueTask<Campaign> GetAsync(Guid id, CancellationToken ct) =>
_cache.TryGetValue(id, out Campaign c)
? ValueTask.FromResult(c)
: new ValueTask<Campaign>(LoadAsync(id, ct));
When to use ValueTask<T>
- Hot path (> 10k calls/sec).
- Likely-synchronous-completion (cache hits, deduplication).
- Documented "await once" contract for consumers.
When NOT to use it
- Public library API — too easy for callers to misuse.
- Methods called rarely — the allocation savings don't matter.
- Anywhere the caller might fanout (
Task.WhenAll) —ValueTaskdoesn't compose cleanly.
For the 99% of code: stick with Task<T>. For the genuine hot paths: ValueTask<T> is a measured optimization.
The benchmark — when it matters
Cache hit, 10M iterations:
Task<T> : 156 ms, 240 MB allocated (one Task per call)
ValueTask<T>: 112 ms, 0 MB allocated (sync completion = no alloc)
Cache miss path:
Task<T> : 8,200 ms, 480 MB allocated
ValueTask<T>: 8,200 ms, 480 MB allocated ← no difference; ValueTask falls back to Task
So ValueTask shines when the fast path is genuinely fast. If you always await an I/O operation, Task<T> is just as good.
Mattrx metric
Converted 4 ultra-hot getters (cache lookups, ID resolvers) to ValueTask<T>. Allocations on /api/dashboard/kpis: -28%. CPU at peak: -2%. Not the headline win; respectable optimization.
13. The full Mattrx before/after
After fixing all 10 mistakes over a 2-week audit:
| Metric | Before | After | Delta |
|---|---|---|---|
| Throughput per instance | 1,200 RPS | 4,500 RPS | +275% |
| Thread-pool worker threads at peak | 110 | 22 | -80% |
threadpool-queue-length p99 | 240 | 0 | — |
| Lock contention / sec at peak | 1,800 | 80 | -96% |
TaskCanceledException (orphaned reqs) / week | 240 | 6 | -98% |
Process crashes from async void / month | 3 | 0 | — |
| Deadlock reports / month | 3 | 0 | — |
| Async state-machine allocations (hot path) | baseline | -40% | -40% |
| GC % time on hot endpoints | 9% | 3% | -67% |
/api/import p95 | 8.2s | 1.4s | -83% |
/api/webhook/replay p95 | 9.4s | 720ms | -92% |
| Sentry "phantom failure" reports / week | 18 | 0 | — |
| App Service tier | P1v3 | P1v3 (could go S1) | could save another $180/mo |
Two weeks of work. No infrastructure changes. No rewrites. The fixes were all 10 boring patterns above.
14. The detection cookbook — what to run when you suspect async issues
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
• alloc-rate climbing under load → state machine bloat
STEP 2 — Find the BLOCKER
dotnet-stack report -p <pid>
Look at thread stacks. If many threads show:
• "Task.GetAwaiter().GetResult()" in the trace → Mistake #1
• Monitor.Wait inside async methods → Mistake #9
• Task.Run sitting in front of async calls → Mistake #4
STEP 3 — Find the LEAK (orphaned tasks)
In Application Insights:
• Search for TaskCanceledException
• Count per minute. If >100/min, you have orphaned reqs (Mistake #5)
STEP 4 — Find sequential awaits that should be parallel
In Application Insights → end-to-end transaction view:
• Find any handler with > 3 sequential await spans, none dependent on the previous
• Each is a candidate for Task.WhenAll
STEP 5 — Find fire-and-forget
grep -nE '_ = \w+Async|Task\.Run\(' src/
Each match is a candidate. Check if exceptions are observed.
STEP 6 — Find async void
grep -nE '(public|private|internal)\s+async\s+void' src/
Each match is a bug. Convert to async Task.
STEP 7 — Find async methods with no await
Roslyn analyzer CA1849 (or VS IDE warning) flags these.
Convert to direct Task return.
STEP 8 — Verify under load
wrk -t8 -c200 -d60s against the slow endpoint
Watch threadpool-queue-length stay at 0.
If it climbs, you missed something — repeat.
Build this into a runbook. The first hour of any async investigation is the same five commands.
15. 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(unless it's a WinForms event handler)? - Are sequential
awaits actually dependent on each other's results? 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, not rawforeach? - No
lock/Monitor.Enterpatterns spanningawait? (UseSemaphoreSlim.) -
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.
16. Honest stuff
- Most async bugs don't show up in dev. Single-user testing never starves a thread pool. Load test before shipping any non-trivial async refactor.
- The biggest win comes from one place: removing
.Result/.Wait()/.GetAwaiter().GetResult(). If your codebase has any, fix that first; the rest of the audit is rounding. - Async is not "concurrency." It's "release the thread." Concurrency is what you get for free because threads are released — multiple requests share the pool.
async voidis a compile-error-in-disguise. Treat it like one. The compiler should warn about it; some analyzers do. Turn them on.Task.Runis almost always wrong inside an ASP.NET Core handler. The exception is genuinely sync CPU work > 50ms. Be honest with yourself about whether your case qualifies.CancellationTokenis cheap to add, expensive to retrofit. Add it to every async method's signature, even if you don't use it yet.ValueTask<T>is a footgun in public APIs. Use only for internal hot paths, document the contract.- Async tests are async too.
xUnit's[Fact] async Taskworks correctly. Don'tTask.Runinside tests. - The .NET 8+
IAsyncEnumerablework (out of scope for this guide) eliminates a whole class of awaiting-in-loops anti-patterns. Worth learning.
17. Closing — the right mental model
In one line: async/await doesn't make code faster. It makes threads available.
Every mistake in this guide is one of:
- Blocking a thread instead of releasing it (
.Result,lockacross await). - Doing sequential I/O when parallel I/O is possible.
- Allocating a state machine you don't need.
- Letting work leak outside its scope.
Three habits that prevent 90% of the pain in this guide:
- Async all the way, from controller signature to the last DB call. No
.Resultexceptions. No "I'll just wait here." - Propagate
CancellationTokenthrough every async method's signature. Free to add now, painful to retrofit later. - Load-test async refactors before shipping.
wrk -t8 -c200 -d60s. Watchthreadpool-queue-length. If it climbs, you missed something.
Apply that, and your /api/import won't take 8 seconds when it could take 1.4, your thread pool won't starve at 1,200 RPS when it could serve 4,500, and your prod won't crash from an async void you wrote three months ago and forgot about.
Further reading
- Microsoft — Async programming best practices — the canonical reference.
- Stephen Cleary's blog — the authority on async in .NET. Read everything.
- Microsoft —
ValueTask<T>guidance — when and how. - Stephen Toub — Async ASP.NET deep dive —
ConfigureAwaitclarified once and for all. PeriodicTimerdocs — replaceSystem.Threading.Timerfor async.Parallel.ForEachAsync— the right primitive for bounded async parallelism.- PrepStack — 10 Hidden Memory Leaks in ASP.NET Core — the memory side of the same audit, including more on fire-and-forget pitfalls.
- PrepStack — Performance Tuning ASP.NET Core APIs — the broader perf playbook this async work sits inside.
Hunting an async issue you can't pin down? Email randhir.jassal@gmail.com with a dotnet-counters snapshot + the offending endpoint signature — happy to point at which of the 10 mistakes likely applies.
Get the next issue
A short, curated email with the newest posts and questions.