When async code blocks anyway
A good waiter is async I/O at its finest. He takes table four's order, hands the ticket to the kitchen, and goes straight back to the floor — pouring drinks, taking orders, seating the party at the door. When the bell rings, he swings by the window and picks up the plate.
A bad waiter takes the same order, walks it to the same kitchen — and then plants himself at the kitchen window. Arms crossed. Staring at the pass. Doing absolutely nothing until the dish comes up. Meanwhile the dining room fills. Table six wants refills. A new party stacks up at the door.
"The kitchen will ring the bell when it's ready. Go work the floor."
"But I need this dish NOW."
And so he stood at the window, blocking, while the dining room starved because nobody was serving tables. One blocked waiter brought down the entire restaurant.
Your API does this every day. You wrote async methods. You added async and await keywords. Code review approved it. The linter is happy. It compiles.
Then you call .Result somewhere. Or .Wait(). Or you use File.ReadAllText() inside an async method. And you just turned your async code back into blocking code — except now it's worse because it blocks thread pool threads under load.
Here's what actually happens when you block async code:
Monday, 2:15 PM — Claims processing API running smoothly, 200 requests/second
Monday, 2:20 PM — Traffic increases to 500 requests/second (pharmacy rush hour)
Monday, 2:22 PM — Every request blocks on .Result while waiting for database
Monday, 2:23 PM — Blocked threads pile up faster than the pool can add new ones (it injects roughly one per second)
Monday, 2:24 PM — Thread pool starvation — requests arrive by the hundred, fresh threads arrive one at a time
Monday, 2:25 PM — New requests queue (no threads available)
Monday, 2:26 PM — Response time: 45ms → 12,000ms
Monday, 2:27 PM — Health checks fail (timeout)
Monday, 2:28 PM — Load balancer removes instances
Monday, 2:29 PM — Pharmacy staff calling: "System down, patients waiting"
Monday, 2:30 PM — You're debugging thread pool exhaustion while wondering why CPU usage is at 8%
Your async code was "correct." The problem was you blocked it.
⚡ The Five Async Mistakes That Kill Production APIs
Every production outage I've investigated involving async code follows the same pattern. Smart, motivated developers make the same five mistakes — not because they're careless, but because async is one of those areas where the "obvious" approach quietly destroys scalability, observability, and reliability.
Let's break down what goes wrong, why it matters, and how to fix it.
❌ Mistake #1: Blocking on Async Code (.Result, .Wait())
The instinct: "It's just one call" or "I'm in a constructor" or "I need the value synchronously here."
What most developers write:
public class ClaimController : ControllerBase
{
private readonly IClaimService _claimService;
public ClaimController(IClaimService claimService)
{
_claimService = claimService;
// ❌ "I need to initialize this in the constructor"
var config = _claimService.GetConfigurationAsync().Result;
ApplyConfiguration(config);
}
[HttpGet("{claimId}")]
public IActionResult GetClaim(string claimId)
{
// ❌ "This method has to return IActionResult, not Task<IActionResult>"
var claim = _claimService.GetClaimAsync(claimId).Result;
return Ok(claim);
}
}
Looks reasonable, right? You need the config in the constructor. You need the claim synchronously. .Result gives you the value. It compiles. It works in dev.
What actually happens in production:
Request 1: Blocks thread #1 waiting for database (200ms)
Request 2: Blocks thread #2 waiting for database (200ms)
Request 3: Blocks thread #3 waiting for database (200ms)
...
Request 100: Blocks thread #100 waiting for database (200ms)
Request 101: No threads available — queues
Request 102: No threads available — queues
Thread pool starved — it adds new threads at roughly one per second, and your traffic doesn't wait that long. Site unresponsive. CPU at 5%. Threads are blocked, not working.
Why it's harmful:
- Deadlocks in classic ASP.NET (System.Web) and UI apps (WPF, WinForms). Those frameworks install a
SynchronizationContextthat pins continuations to a single thread. The async method tries to resume on the original context, but you're blocking that context waiting for it to complete. Classic deadlock. ASP.NET Core does not install aSynchronizationContext— so.Resultthere doesn't deadlock on a request context, it just burns a thread. The result is thread-pool starvation, not a lockup. - Blocks thread pool threads. That thread sits there doing nothing while waiting for the database. Under load, you run out of threads. This is the failure mode you'll actually see in ASP.NET Core.
- Turns async code back into sync code. The
asyncstate machine is a struct (in Release builds) that only gets boxed if the method actually suspends, so the allocation cost is usually small — but you still got none of the benefits and made it worse by blocking.
The fix: Actually use async
public class ClaimController : ControllerBase
{
private readonly IClaimService _claimService;
private readonly ClaimConfiguration _config;
// ✅ Inject configuration — don't load it in constructor
public ClaimController(
IClaimService claimService,
IOptions<ClaimConfiguration> config)
{
_claimService = claimService;
_config = config.Value;
}
[HttpGet("{claimId}")]
public async Task<IActionResult> GetClaim(
string claimId, CancellationToken cancellationToken)
{
// ✅ Actually async — thread released while waiting for I/O
var result = await _claimService.GetClaimAsync(claimId, cancellationToken);
if (!result.IsSuccess)
return NotFound(new ProblemDetails { Detail = result.Error });
return Ok(result.Value);
}
}
What changed:
- Constructor doesn't do async work — inject
IOptions<ClaimConfiguration>instead - Controller method is actually
async(returnsTask<IActionResult>) CancellationTokenpassed through so requests can be canceled- Thread released while waiting for database
Now under load: Same 10 threads handle 1,000 concurrent requests. No thread pool exhaustion.
❌ Mistake #2: async void (The App Crasher)
The instinct:
"I can't await this, so I'll make it void."
What most developers write:
// ❌ async void — exceptions crash the entire app
public async void ProcessClaim(string claimId)
{
var claim = await _repository.GetClaimAsync(claimId);
await _processor.ProcessAsync(claim);
// Exception here? Unhandled. App crashes.
}
// Called from somewhere
public void HandleClaimUpdate(string claimId)
{
ProcessClaim(claimId); // Fire-and-forget
// Can't await this — it returns void
// Can't track completion
// Can't catch exceptions
}
Looks harmless, right? You just want to kick off some processing. The method does async work. The async keyword is there. It compiles.
What actually happens in production:
Unhandled exception in async void method
→ No Task to hold the exception
→ Thrown on a thread-pool thread (ASP.NET Core has no SynchronizationContext)
→ The caller can't catch it — there's nothing to await
→ Application crashes
→ Logical call stack lost — the dump shows the throw, not who called you
→ App restarts
→ Users see 503
async void is the only async pattern where unhandled exceptions terminate the process. An async Task method that throws produces a faulted Task — the exception is captured and can be observed later. An async void method that throws has nowhere to put the exception. It crashes the app.
Why it's harmful:
- Exceptions bypass normal handling. An
async voidmethod doesn't produce aTask, so there's no way to await it and observe the exception. If aSynchronizationContextwas captured when the method started, the exception is posted there; otherwise it's thrown on a thread-pool thread. In ASP.NET Core (no SyncContext) and console apps, that means the exception firesAppDomain.UnhandledExceptionand crashes the process. - Caller can't
awaitit. No way to know when it completes, succeeded, or failed. - No completion tracking. The caller fires it and hopes for the best.
The fix: Return Task, always
// ✅ Returns Task — caller can await, exceptions propagate normally
public async Task ProcessClaimAsync(
string claimId, CancellationToken cancellationToken)
{
var claim = await _repository.GetClaimAsync(claimId, cancellationToken);
await _processor.ProcessAsync(claim, cancellationToken);
}
// ✅ Caller can await
public async Task HandleClaimUpdateAsync(
string claimId, CancellationToken cancellationToken)
{
await ProcessClaimAsync(claimId, cancellationToken);
}
If you need fire-and-forget semantics, don't reach for async void — queue it:
// ✅ Background queue instead of async void
public async Task QueueClaimUpdateAsync(
string claimId, CancellationToken cancellationToken)
{
await _backgroundQueue.EnqueueAsync(
new ProcessClaimJob { ClaimId = claimId }, cancellationToken);
}
The ONLY valid use of async void — event handlers:
// ✅ Event handlers return void — no other option
button.Click += async (sender, e) =>
{
try
{
await ProcessClaimAsync(claimId, CancellationToken.None);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to process claim {ClaimId}", claimId);
}
};
Event handlers have no return value, so async void is the only option. But always wrap the body in try/catch — there's no caller to observe a faulted Task.
What changed:
- Returns
Taskinstead ofvoid— exceptions propagate normally - Caller can
awaitor queue for background processing CancellationTokenthreaded through for cancellation support
❌ Mistake #3: Fire-and-Forget Without Error Handling
The instinct: "I don't want to wait for this email to send, just fire it off."
What most developers write:
[HttpPost]
public async Task<IActionResult> ProcessClaim(
ProcessClaimRequest request, CancellationToken cancellationToken)
{
var result = await _claimService.ProcessClaimAsync(request, cancellationToken);
if (!result.IsSuccess)
return BadRequest(new ProblemDetails { Detail = result.Error });
// ❌ Fire-and-forget — exceptions disappear into the void
_ = _emailService.SendClaimApprovalEmailAsync(
result.Value.Id, result.Value.PatientEmail, CancellationToken.None);
return CreatedAtAction(
nameof(GetClaim),
new { claimId = result.Value.Id },
result.Value);
}
Looks clever, right? You don't want the user waiting for an email to send. The discard (_ =) keeps the compiler happy. The request returns fast. Ship it.
What actually happens in production:
Claims processed: 1,000 ✅
Approval emails sent: 0 ❌
Logs showing email failures: 0
Monitoring alerts: 0
Customer complaints (3 days later): "I never got the approval email"
The email service went down at 2:15 PM. Nobody knew until patients started calling. Every exception was discarded. No log. No metric. No alert. Three days of missing emails before anyone noticed.
Why it's harmful:
- Exceptions disappear. The discarded
Taskswallows every failure silently. - No observability. Can't track success rate, failure rate, or latency.
- Silent failures compound. By the time users notice, thousands of emails are missing.
The fix: Background queue with error handling
[HttpPost]
public async Task<IActionResult> ProcessClaim(
ProcessClaimRequest request, CancellationToken cancellationToken)
{
var result = await _claimService.ProcessClaimAsync(request, cancellationToken);
if (!result.IsSuccess)
return BadRequest(new ProblemDetails { Detail = result.Error });
// ✅ Queue for background processing with retry and logging
await _backgroundJobQueue.EnqueueAsync(new SendClaimApprovalEmail
{
ClaimId = result.Value.Id,
PatientEmail = result.Value.PatientEmail
}, cancellationToken);
return CreatedAtAction(
nameof(GetClaim),
new { claimId = result.Value.Id },
result.Value);
}
// Background service processes the queue
public class EmailBackgroundService : BackgroundService
{
private readonly IBackgroundJobQueue _queue;
private readonly IEmailService _emailService;
private readonly ILogger<EmailBackgroundService> _logger;
public EmailBackgroundService(
IBackgroundJobQueue queue,
IEmailService emailService,
ILogger<EmailBackgroundService> logger)
{
_queue = queue;
_emailService = emailService;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await foreach (var job in _queue.DequeueAsync(stoppingToken))
{
try
{
await _emailService.SendClaimApprovalEmailAsync(
job.ClaimId, job.PatientEmail, stoppingToken);
_logger.LogInformation(
"Approval email sent for claim {ClaimId}", job.ClaimId);
}
catch (Exception ex)
{
_logger.LogError(ex,
"Failed to send approval email for claim {ClaimId}",
job.ClaimId);
// Retry queue, dead letter queue, or alerting
}
}
}
}
What changed:
- Email queued for background processing — request stays fast
- Errors logged and tracked — you know when email fails
- Retry logic possible — transient failures handled automatically
BackgroundServiceruns for the lifetime of the app — no orphaned tasks
❌ Mistake #4: Task.Run for I/O-Bound Work
The instinct:
"I want this to run in parallel" or "I read that Task.Run makes things async."
What most developers write:
// ❌ Wrapping I/O-bound work in Task.Run
public Task<Claim> GetClaimAsync(string claimId)
{
return Task.Run(() => _repository.GetClaim(claimId));
// Allocates a thread pool thread just to wait for I/O
// The thread sits there blocked — worse than sync
}
// ❌ Wrapping already-async work in Task.Run
public Task<Result<Claim>> ProcessClaimAsync(
ClaimRequest request, CancellationToken cancellationToken)
{
return Task.Run(async () =>
{
var claim = await _repository.GetClaimAsync(
request.ClaimId, cancellationToken);
return await _processor.ProcessAsync(claim, cancellationToken);
});
// Pointless — already async, just adds overhead
}
Looks like good parallelism, right? Task.Run means "run on the thread pool." Async means parallel. More threads means faster. Ship it.
What actually happens in production:
The first example grabs a thread pool thread and immediately blocks it waiting for database I/O. You haven't made anything async — you've just moved the blocking to a different pool thread and paid scheduling overhead for the privilege. Same number of blocked threads as calling the sync method directly, plus the tax.
The second example wraps code that's already async in Task.Run. The inner await already releases the thread. The outer Task.Run allocates an extra thread, extra context switch, extra allocations — for zero benefit.
Why it's harmful:
- Wastes threads.
Task.Rungrabs a thread pool thread. If that thread just waits for I/O, you've consumed a thread for nothing. - Adds overhead. Extra allocations, extra context switches, no benefit.
- Hides the real problem. If your repository method is synchronous, wrapping it in
Task.Rundoesn't fix it — it just moves the blocking to a different thread.
The fix: Understand what Task.Run is for
The rule is simple:
- I/O-bound work (database, HTTP calls, file system): Use async/await directly — don't wrap in Task.Run
- CPU-bound work (calculations, parsing, compression): Use Task.Run to offload to the thread pool
Most ASP.NET Core work is I/O-bound. If you're calling a database, an API, or reading a file — don't use Task.Run.
When Task.Run is actually useful — CPU-bound work:
public async Task<AnalysisResult> AnalyzeClaimsAsync(
List<Claim> claims, CancellationToken cancellationToken)
{
// ✅ Offload CPU-intensive work to thread pool
var analysisTask = Task.Run(() =>
PerformStatisticalAnalysis(claims, cancellationToken), cancellationToken);
// Do other async I/O while CPU work runs in parallel
var historicalData = await _repository.GetHistoricalDataAsync(
cancellationToken);
// Wait for CPU work to complete
var analysis = await analysisTask;
return CombineResults(analysis, historicalData);
}
What changed:
Task.Runused only for CPU-heavy computation (PerformStatisticalAnalysis)- I/O work (
GetHistoricalDataAsync) uses normalasync/await - Both run in parallel — CPU work on a thread pool thread, I/O work releasing threads
CancellationTokenpassed into the analysis, not just toTask.Run— the token you handTask.Runonly cancels work that hasn't started yet
The test: Is the work I/O-bound or CPU-bound? If the thread is waiting for something external, use async/await. If the thread is computing something, use Task.Run.
💥 Mistake #5: Sync I/O Inside Async Methods (The Silent Killer)
This is the most devastating async mistake. Not because it fails harder than .Result — in ASP.NET Core it fails exactly the same way — but because nobody's watching for it.
Every mistake so far has a reputation. .Result is infamous — every senior dev has the scar, every reviewer flags it on sight. async void crashes apps. Fire-and-forget produces customer complaints. But File.ReadAllText() inside an async method? No reputation at all. It passes every code review. It works in development. It works in staging. It works under light production load. And then it kills your site during the busiest hour of the busiest day, and nobody can figure out why.
What most developers write:
public async Task<Result<ClaimReport>> GenerateReportAsync(
string claimId, CancellationToken cancellationToken)
{
var claim = await _repository.GetClaimAsync(claimId, cancellationToken);
// ❌ SYNC I/O inside async method — blocks thread pool thread
var template = File.ReadAllText("templates/report.html");
var report = GenerateHtml(claim, template);
// ❌ SYNC I/O — blocks thread
File.WriteAllText($"reports/{claimId}.html", report);
return Result<ClaimReport>.Success(new ClaimReport
{
Path = $"reports/{claimId}.html"
});
}
public async Task<Result<List<Claim>>> GetClaimsFromFileAsync(
CancellationToken cancellationToken)
{
// ❌ SYNC I/O — blocks thread while reading entire file
var json = File.ReadAllText("claims.json");
var claims = JsonSerializer.Deserialize<List<Claim>>(json) ?? [];
return Result<List<Claim>>.Success(claims);
}
Looks perfectly fine, right? The method is async. It uses await for the database call. File.ReadAllText() is just reading a template — how bad can it be? Code review approves it. It ships.
What actually happens in production:
Development (10 requests/second):
→ Works perfectly
→ No issues detected
→ Tests pass
→ Ship it
Production (500 requests/second):
→ Thread pool exhausted in 3 seconds
→ Response time: 45ms → 25,000ms
→ CPU usage: 12% (threads blocked, not working)
→ New requests queue infinitely
→ Health checks fail
→ Load balancer removes instances
→ Site down
Why this is worse than every other mistake in this post:
.Resultis famous — reviewers flag it on sight, analyzers light it up, everyone knows the ruleFile.ReadAllText()looks "normal" and sails through every code review- But in ASP.NET Core they fail identically — a blocked thread is a blocked thread, whether
.Resultblocked it or sync I/O did - Both cause silent thread starvation — fine in dev, dead under load, nearly impossible to reproduce locally
Why it's harmful:
- Consumes a thread pool thread for the entire I/O operation. Reading a 100MB file? Thread blocked for the whole time. Every concurrent request doing the same thing blocks another thread.
- Causes thread starvation under load. 100 concurrent requests = 100 blocked threads = thread pool exhausted = site down.
- CPU stays low while everything hangs. This is the signature. Your monitoring shows 8% CPU and 25-second response times. Threads aren't working — they're waiting.
- Adding servers doesn't help. Same code, same problem, more servers just means more threads blocked in the same place.
The fix: Use async I/O methods
public async Task<Result<ClaimReport>> GenerateReportAsync(
string claimId, CancellationToken cancellationToken)
{
var claim = await _repository.GetClaimAsync(claimId, cancellationToken);
// ✅ ASYNC I/O — thread released while reading
var template = await File.ReadAllTextAsync(
"templates/report.html", cancellationToken);
var report = GenerateHtml(claim, template);
// ✅ ASYNC I/O — thread released while writing
await File.WriteAllTextAsync(
$"reports/{claimId}.html", report, cancellationToken);
return Result<ClaimReport>.Success(new ClaimReport
{
Path = $"reports/{claimId}.html"
});
}
public async Task<Result<List<Claim>>> GetClaimsFromFileAsync(
CancellationToken cancellationToken)
{
// ✅ ASYNC I/O — thread released while reading file
// File.OpenRead does NOT enable async I/O; you have to opt in with useAsync: true,
// otherwise the reads are sync I/O wrapped on a thread-pool thread.
await using var stream = new FileStream(
"claims.json",
FileMode.Open,
FileAccess.Read,
FileShare.Read,
bufferSize: 4096,
useAsync: true);
var claims = await JsonSerializer.DeserializeAsync<List<Claim>>(
stream, cancellationToken: cancellationToken) ?? [];
return Result<List<Claim>>.Success(claims);
}
What changed:
File.ReadAllText()→File.ReadAllTextAsync()— thread released while readingFile.WriteAllText()→File.WriteAllTextAsync()— thread released while writingJsonSerializer.Deserialize()→JsonSerializer.DeserializeAsync()with a stream — async and memory-efficientCancellationTokenpassed to every I/O call
Sync I/O culprits to watch for:
// ❌ All of these block threads
File.ReadAllText() // → File.ReadAllTextAsync()
File.WriteAllText() // → File.WriteAllTextAsync()
File.ReadAllBytes() // → File.ReadAllBytesAsync()
stream.Read() // → stream.ReadAsync()
stream.Write() // → stream.WriteAsync()
SqlCommand.ExecuteReader() // → SqlCommand.ExecuteReaderAsync()
// Not I/O, but it blocks the thread all the same:
Thread.Sleep() // → await Task.Delay()
// Not sync I/O, but related: calling httpClient.GetAsync() without awaiting it
// is fire-and-forget — the request runs unobserved, exceptions are swallowed.
// Always await async calls.
The litmus test: If you're doing I/O inside an async method and you don't see await, you're blocking threads.
🎓 Why These Patterns Matter
Every async outage follows the same script:
Async code blocks threads → Thread pool exhausts → Response times spike → Site becomes unresponsive → CPU stays low → Nobody knows why
Picture two teams building claims processing APIs. Same load. Same infrastructure.
Team A writes "async" code:
- Methods marked async
- Some .Result calls ("just a few")
- File.ReadAllText() inside async methods ("it's just config")
- async void on a few background methods ("it works fine")
- Works great in development (10 req/sec)
- Production deployment (500 req/sec):
- Thread pool exhausted in 30 seconds
- Response time: 45ms → 18,000ms
- CPU: 8% (threads blocked, not working)
- Adding more servers doesn't help (same code, same problem)
- Site down during peak hours
- Rollback
Team B writes actually async code:
- Methods are async AND don't block
- All I/O uses async methods
- No .Result, no .Wait(), no sync File APIs
- CancellationToken threaded through every call
- Background work uses BackgroundService with error handling
- Thread pool handles 1,000+ concurrent requests
- Response time stays 45–60ms under load
- CPU scales with actual work
- Site handles peak load
Same infrastructure. One team blocks threads. The other doesn't.
The difference isn't "async/await keywords" — it's understanding what async actually means: don't block threads waiting for I/O.
🧭 Key Takeaways
- Never block on async code — no
.Result, no.Wait(), ever async voidcrashes apps — always returnTask, except event handlers- Fire-and-forget needs error handling — use
BackgroundServiceand queues, not discardedTasks Task.Runis for CPU-bound work — not I/O-bound work- Sync I/O in async methods is the silent killer —
File.ReadAllText()inside anasyncmethod blocks threads just like.Result, but passes every code review
🚀 Next Steps
Review your async code:
- Search for
.Resultand.Wait()— replace withawait - Search for
async void— replace withasync Task - Search for
_ =discarded tasks — add background processing with error handling - Search for
Task.Runwrapping I/O — remove theTask.Run, justawait - Search for
File.ReadAllText,File.WriteAllText,Thread.Sleep— replace with async versions
Start with your hottest code paths (most frequently called endpoints). Make them actually async. Load test to verify the thread pool doesn't exhaust.
The APIs that scale under load don't block threads. The ones that crash do.
One waiter blocked the whole restaurant standing at the kitchen window. Hand off the ticket, work the floor, come back when the bell rings. Don't let your API make the same mistake.
Related Posts:
- Exception Handling That Survives Production — Result<T> patterns referenced here
In the next post: Entity Framework Core performance patterns — understanding query execution, avoiding N+1 queries, and the patterns that prevent your ORM from destroying your database.