Publishing Bi-Weekly · ASP.NET Core · Design Patterns · Architecture · 20 yrs C#/.NET · Jacksonville, FL · appliedcsharp.com

⚡ Async/Await — Don't Block Your Threads

When async code blocks anyway


The Professor built an async coconut radio — it could listen for passing ships without anyone standing there holding it. Just check back later when a signal arrived. Async I/O at its finest.

But Gilligan didn't understand async. He wanted to know if it detected a ship RIGHT NOW. So he grabbed the radio, held it to his ear, and called .Result — blocking himself from doing anything else while waiting for a signal.

"Gilligan, the whole point is you don't have to wait. Gather coconuts, build a hut — check back later."

"But I need to know NOW."

And so Gilligan stood there, blocking, while seven castaways starved because nobody could gather food. One blocked thread brought down the entire island's operation.

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 — Thread pool has 100 threads available
Monday, 2:23 PM — Every request blocks on .Result while waiting for database
Monday, 2:24 PM — Thread pool exhausted (all 100 threads blocked)
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 exhausted. Site unresponsive. CPU at 5%. Threads are blocked, not working.

Why it's harmful:

  1. Deadlocks in ASP.NET and UI apps. The async method tries to resume on the original context, but you're blocking that context waiting for it to complete. Classic deadlock.
  2. Blocks thread pool threads. That thread sits there doing nothing while waiting for the database. Under load, you run out of threads.
  3. Turns async code back into sync code. You paid the allocation cost of the async state machine, 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 (returns Task<IActionResult>)
  • CancellationToken passed through so requests can be cancelled
  • 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
→ Exception propagates to SynchronizationContext
→ No try/catch can save you
→ Application crashes
→ Stack trace lost
→ 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:

  1. Exceptions bypass normal handling. They don't produce a faulted Task — they propagate to the SynchronizationContext and crash the process.
  2. Caller can't await it. No way to know when it completes, succeeded, or failed.
  3. 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 Task instead of void — exceptions propagate normally
  • Caller can await or queue for background processing
  • CancellationToken threaded 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);

    return CreatedAtAction(
        nameof(GetClaim),
        new { id = 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:

  1. Exceptions disappear. The discarded Task swallows every failure silently.
  2. No observability. Can't track success rate, failure rate, or latency.
  3. 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 { id = 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, 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
  • BackgroundService runs 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 from one thread to another and added allocation overhead. Under load, you exhaust the thread pool faster than if you'd just called the sync method directly.

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:

  1. Wastes threads. Task.Run grabs a thread pool thread. If that thread just waits for I/O, you've consumed a thread for nothing.
  2. Adds overhead. Extra allocations, extra context switches, no benefit.
  3. Hides the real problem. If your repository method is synchronous, wrapping it in Task.Run doesn'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);

    // 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.Run used only for CPU-heavy computation (PerformStatisticalAnalysis)
  • I/O work (GetHistoricalDataAsync) uses normal async/await
  • Both run in parallel — CPU work on a thread pool thread, I/O work releasing threads
  • CancellationToken passed to Task.Run so it can be cancelled

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. It's worse than .Result because it looks completely harmless and only fails under load.

Every mistake so far has been visible — .Result causes deadlocks you can diagnose, async void crashes apps with stack traces, fire-and-forget produces customer complaints. But sync I/O inside async methods? 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:

  • .Result causes obvious deadlocks — fails immediately in dev, easy to debug
  • Sync I/O causes silent thread starvation — only fails under load, nearly impossible to reproduce locally
  • .Result is clearly wrong in code review — everyone knows not to do it
  • File.ReadAllText() looks "normal" and sails through every code review

Why it's harmful:

  1. 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.
  2. Causes thread starvation under load. 100 concurrent requests = 100 blocked threads = thread pool exhausted = site down.
  3. 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.
  4. 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
    await using var stream = File.OpenRead("claims.json");
    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 reading
  • File.WriteAllText()File.WriteAllTextAsync() — thread released while writing
  • JsonSerializer.Deserialize()JsonSerializer.DeserializeAsync() with a stream — async and memory-efficient
  • CancellationToken passed 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()
HttpClient without await    // → await httpClient.GetAsync()
Thread.Sleep()              // → await Task.Delay()

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 void crashes apps — always return Task, except event handlers
  • Fire-and-forget needs error handling — use BackgroundService and queues, not discarded Tasks
  • Task.Run is for CPU-bound work — not I/O-bound work
  • Sync I/O in async methods is the silent killerFile.ReadAllText() inside an async method blocks threads just like .Result, but passes every code review

🚀 Next Steps

Review your async code:

  1. Search for .Result and .Wait() — replace with await
  2. Search for async void — replace with async Task
  3. Search for _ = discarded tasks — add background processing with error handling
  4. Search for Task.Run wrapping I/O — remove the Task.Run, just await
  5. 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.

Gilligan blocked the island calling .Result. Don't let your API make the same mistake.


Related Posts: - Building Professional WebAPI Controllers — Async controller patterns - When the CRUD Hits the Fan — Async resilience patterns - Exception Handling That Survives ProductionResult<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.