ASP.NET Hosting

C# Async/Await Pitfalls: The Errors That Compile Correctly and Fail in Production

The simplest C# syntax to use both correctly and poorly with complete certainty is async/await. Either way, the code compiles. If you block on a Task, neglect an await, or write async void, the compiler won’t stop you; instead, it will just sit there, green checkmark and all, waiting for production traffic to discover the bug you missed.

Here are six common mistakes found in actual ASP.NET Core codebases, along with the cure for each.

Pitfall 1: async void — Exceptions That Vanish Into Thin Air

// Approving a leave request, then firing off a notification
public void ApproveLeave(Guid leaveRequestId)
{
    var leaveRequest = _repository.GetById(leaveRequestId);
    leaveRequest.Approve();
    _repository.Save(leaveRequest);

    NotifyManagerAsync(leaveRequest); // looks fine. it is not fine.
}

public async void NotifyManagerAsync(LeaveRequest leaveRequest)
{
    await _emailService.SendAsync(leaveRequest.ManagerEmail, "Your team member's leave was approved");
}

This compiles. It even works — right up until the SMTP server has a bad day. When SendAsync throws inside an async void method, there’s no Task for anyone to observe, so the exception can’t be caught by a try/catch around the call site at all. It gets re-thrown directly on whatever SynchronizationContext was active when the method started — or marshaled straight to the thread pool if there isn’t one. An unhandled exception on any thread in .NET terminates the process by default. There’s no quiet “logged and ignored” version of this failure — it’s silence right up until it takes the whole app down with it.

The fix — make it async Task, and actually await it:

public async Task ApproveLeaveAsync(Guid leaveRequestId)
{
    var leaveRequest = await _repository.GetByIdAsync(leaveRequestId);
    leaveRequest.Approve();
    await _repository.SaveAsync(leaveRequest);

    await NotifyManagerAsync(leaveRequest);
}

public async Task NotifyManagerAsync(LeaveRequest leaveRequest)
{
    await _emailService.SendAsync(leaveRequest.ManagerEmail, "Your team member's leave was approved");
}

If you genuinely want fire-and-forget — approval shouldn’t wait on the email — don’t reach for async void. Handle the exception explicitly instead:

_ = Task.Run(async () =>
{
    try
    {
        await NotifyManagerAsync(leaveRequest);
    }
    catch (Exception ex)
    {
        _logger.LogError(ex, "Failed to notify manager for leave request {Id}", leaveRequest.Id);
    }
});

The only legitimate use of async void is a UI event handler whose signature is dictated by the framework (a WPF Button.Click handler, for example) and can’t be changed to return Task. Anywhere else — services, repositories, business logic — it’s a trap with a green checkmark on it.

Pitfall 2: Blocking on Async Code With .Result or .Wait()

public LeaveBalance GetLeaveBalance(Guid employeeId)
{
    // "I just need the value right now" — famous last words
    var balance = _leaveBalanceRepository.GetByEmployeeIdAsync(employeeId).Result;
    return balance;
}

This one has a specific, well-documented failure mode: in any environment with a SynchronizationContext — classic ASP.NET (pre-Core), WPF, WinForms — this deadlocks under the right conditions. The awaited continuation inside GetByEmployeeIdAsync wants to resume back on the original context, but that context’s one and only thread is the thread currently frozen at .Result, waiting for the continuation to finish. Two things waiting on each other, forever.

ASP.NET Core specifically doesn’t have a SynchronizationContext by default, so this exact deadlock is less likely to bite you there — which is exactly why it’s dangerous: it can sit in a shared library, work fine when called from an ASP.NET Core controller, and then deadlock the moment someone reuses that same library from a WinForms tool or a classic ASP.NET MVC project six months later.

The fix — async all the way up, no exceptions:

public async Task<LeaveBalance> GetLeaveBalanceAsync(Guid employeeId)
{
    return await _leaveBalanceRepository.GetByEmployeeIdAsync(employeeId);
}

If a method calls an async method, it needs to be async itself. The moment you write .Result or .Wait() to “convert” async code back into sync code, you’ve just reintroduced the exact problem async/await existed to solve.

Pitfall 3: Forgetting the await — The Bug the Compiler Tried to Warn You About

public async Task<Guid> CreateLeaveRequestAsync(CreateLeaveRequestCommand command)
{
    var leaveRequest = new LeaveRequest(command.EmployeeId, command.StartDate, command.EndDate);
    await _repository.AddAsync(leaveRequest);

    _auditService.LogLeaveCreatedAsync(leaveRequest.Id); // missing await

    return leaveRequest.Id;
}

The compiler actually flags this one — CS4014: Because this call is not awaited, execution of the current method continues before the call is completed — but in a Controller action with a dozen other warnings about nullable references, it’s easy to scroll right past. The method returns, the HTTP response goes back to the client, and the audit log write keeps running in the background on borrowed time. If it touches a scoped DbContext or HttpContext that gets disposed once the request ends, it can fail with an ObjectDisposedException — deep inside a task nobody’s watching, in a request that already returned 200 OK ten milliseconds ago.

The fix is almost insultingly simple:

await _auditService.LogLeaveCreatedAsync(leaveRequest.Id);

Treat that CS4014 warning as seriously as a compile error. It usually is one, just delayed until production traffic finds it.

Pitfall 4: Awaiting Sequentially When Nothing Depends on Anything Else

public async Task<DashboardData> BuildDashboardAsync(int companyId)
{
    var complianceStatus = await _complianceService.GetStatusAsync(companyId);
    var leaveSummary = await _leaveService.GetMonthlySummaryAsync(companyId);
    var employeeCount = await _employeeService.GetActiveCountAsync(companyId);

    return new DashboardData(complianceStatus, leaveSummary, employeeCount);
}

This is exactly the kind of code that ends up powering a dashboard page — three calls, none of which need each other’s results, written one after another simply because that’s the order they were typed in. If each call takes around 300ms, this method takes roughly 900ms, even though there was no reason for the second call to wait for the first.

The fix — start them all, then await together:

public async Task<DashboardData> BuildDashboardAsync(int companyId)
{
    var complianceTask = _complianceService.GetStatusAsync(companyId);
    var leaveTask = _leaveService.GetMonthlySummaryAsync(companyId);
    var employeeTask = _employeeService.GetActiveCountAsync(companyId);

    await Task.WhenAll(complianceTask, leaveTask, employeeTask);

    return new DashboardData(complianceTask.Result, leaveTask.Result, employeeTask.Result);
}

Same three calls, now running concurrently. Total time drops to roughly however long the slowest one takes — around 300ms instead of 900ms — just by not awaiting each one before starting the next.

Pitfall 5: Wrapping Synchronous Code in Task.Run Inside a Web API

[HttpGet]
public async Task<IActionResult> GetReport()
{
    var report = await Task.Run(() => _reportGenerator.GenerateReport()); // doesn't help
    return Ok(report);
}

This usually comes from a good instinct pointed the wrong way — someone hears “async is faster” and wraps a slow synchronous method in Task.Run to “make it async.” Inside an ASP.NET Core application, this doesn’t help, and under load it actively hurts. The request is already running on a thread pool thread. Task.Run grabs a second thread pool thread to run the same blocking work, then frees the first thread for a moment before needing another one anyway to resume. Do this across enough concurrent requests and you’re not adding concurrency — you’re starving the same thread pool from two directions at once.

The fix depends on what GenerateReport actually does. If it’s genuinely synchronous, CPU-bound work, just call it directly — there’s no async to fake here:

[HttpGet]
public IActionResult GetReport()
{
    var report = _reportGenerator.GenerateReport();
    return Ok(report);
}

If it’s doing real I/O (database calls, an external API), make the I/O itself actually async instead of wrapping a sync method in Task.Run:

[HttpGet]
public async Task<IActionResult> GetReport()
{
    var report = await _reportGenerator.GenerateReportAsync();
    return Ok(report);
}

Task.Run earns its keep in client applications — WPF, WinForms — where the goal is keeping a single UI thread responsive. On the server, where everything is already multithreaded, it’s rarely solving a real problem.

Pitfall 6: Task.WhenAll Only Surfaces One Exception, Not All of Them

var task1 = _service.CallApiAAsync(); // throws TimeoutException
var task2 = _service.CallApiBAsync(); // throws InvalidOperationException

try
{
    await Task.WhenAll(task1, task2);
}
catch (Exception ex)
{
    // ex is only ONE of the two — whichever exception the await happens to rethrow
    _logger.LogError(ex, "A call failed");
}

Both tasks failed, but awaiting the combined Task.WhenAll only re-throws a single exception, so your logs show one failure when there were actually two unrelated ones. This is rarely caught in testing because it only shows up when multiple things fail simultaneously — exactly the kind of moment you’d most want complete information.

The fix — inspect the AggregateException on the Task itself, not just what await rethrows:

var allTasks = Task.WhenAll(task1, task2);

try
{
    await allTasks;
}
catch
{
    if (allTasks.Exception is { } aggregate)
    {
        foreach (var ex in aggregate.InnerExceptions)
        {
            _logger.LogError(ex, "A parallel call failed");
        }
    }
}

allTasks.Exception holds the full AggregateException with every failure inside InnerExceptions — the information was never actually lost, it just wasn’t where the catch block was looking.

Quick Reference

Pitfall Symptom Fix
async void Exceptions vanish or crash the process Use async Task; handle fire-and-forget explicitly with try/catch
.Result / .Wait() Deadlocks where a SynchronizationContext exists; thread waste elsewhere Be async all the way up the call stack
Missing await Silent bugs, ObjectDisposedException later, CS4014 warning Always await; never ignore CS4014
Sequential await on independent calls Slow response times that add up Task.WhenAll for calls that don’t depend on each other
Task.Run around sync code in a Web API Thread pool starvation under load Don’t fake async server-side; make real I/O actually async instead
Task.WhenAll + multiple failures Only one exception logged, others silently lost Read task.Exception.InnerExceptions

Bonus: Interview Questions That Go Beyond “What Is Async/Await”

“Why does calling .Result on an async method sometimes deadlock and sometimes not?”

It depends on whether a SynchronizationContext is present. Classic ASP.NET and UI frameworks (WPF, WinForms) have one, and that’s where the deadlock happens — the awaited continuation can’t resume because the one thread it needs is blocked on .Result. ASP.NET Core has no SynchronizationContext by default, so this exact deadlock typically doesn’t occur there — but .Result is still a wasteful, anti-pattern, and shared libraries that block synchronously can deadlock the moment they’re reused in a context that does have one.

“What’s wrong with async void, and is there ever a legitimate use for it?”

Exceptions thrown inside an async void method can’t be caught by the caller, because there’s no Task representing the operation for anyone to await or inspect. The one legitimate use is a UI event handler whose method signature is fixed by the framework (void is all it allows) — everywhere else, including services and business logic, it should be async Task.

“If you don’t await a Task, does the underlying code still execute?”

Yes. Calling an async method starts running it immediately, up to its first incomplete await (or fully, if nothing it awaits is actually asynchronous). Not awaiting the returned Task doesn’t stop the work — it just means you have no way of knowing when it finishes or whether it threw.

“When does Task.WhenAll actually help over awaiting in a loop?”

When the operations are independent — they don’t need each other’s results to proceed. Awaiting one at a time in a loop adds every operation’s latency together. Task.WhenAll lets them run concurrently, so the total time approaches the slowest single operation instead of the sum of all of them.

“Does wrapping a method in Task.Run make an ASP.NET Core endpoint faster?”

Usually no, and it can make things worse under load. The request is already being served by a thread pool thread; Task.Run borrows a second one to do the same blocking work, which contributes to thread pool starvation rather than relieving it. Task.Run makes more sense in client applications trying to keep a UI thread free — not in server code that’s already multithreaded by design.

The Takeaway

None of these six pitfalls produce a compiler error. That’s exactly what makes async/await dangerous in a way that, say, a missing semicolon isn’t — every one of these bugs ships, passes code review, and waits for production load, a slow network call, or two failures happening at once before it actually shows itself. The fix in every case isn’t a clever trick — it’s the same discipline repeated six different ways: be async all the way up, always observe what a Task is telling you, and never assume that “it compiled” means “it’s correct.”