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
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:
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:
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()
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:
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
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:
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
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:
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
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:
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:
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
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:
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.”
