Async/await is the easiest syntax in C# to use correctly by accident and incorrectly with total confidence. The code compiles either way. The compiler won't stop you from blocking on a Task, forgetting an await, or writing async void — it'll just sit there, green checkmark and all, waiting for production traffic to find the bug you didn't.
Here are six pitfalls that show up constantly in real ASP.NET Core codebases, each with the broken version and the fix.
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.

Join the conversation! Your thoughts help the community grow.