← Open in the full interactive course (progress tracking, search & more)

Every mistake on this page compiles. That's exactly what makes them dangerous.

Across this module, you've learned Task, async/await, CancellationToken, Task.WhenAll/WhenAny, exception handling in async code, and async streams. Each of those lessons showed you the correct way to use its tool. This lesson looks at the same territory from the opposite direction: the five mistakes developers make with these exact tools most often — mistakes the compiler happily lets through, that only reveal themselves later, as a hung UI, a silently swallowed exception, or a production outage nobody can reproduce locally.

In this lesson, you'll walk through five real async pitfalls — async void, accidentally-unawaited Tasks, blocking with .Result/.Wait(), dropping CancellationToken, and misusing Task.Run — see exactly why each one is dangerous, and learn the fix for each. Think of it as this module's final exam, graded against real bugs instead of trivia.

What Is It?

The Simple Explanation

An "async mistake," in the sense used on this page, is code that compiles cleanly and often even appears to work in casual testing, but violates one of the rules this module has been building toward — usually because it quietly reintroduces blocking, silently drops an exception, or breaks the "async all the way" chain from a couple of lessons ago.

The Technical Definition

What unites all five mistakes below is that they violate an invariant of the async model rather than a syntax rule: "an exception from an async void method can't be caught by the caller," "a Task that's never awaited or observed can fail silently," "blocking a thread on a Task can deadlock in certain contexts," "a CancellationToken that isn't passed down can't be honored deeper in the chain," and "Task.Run wrapping I/O-bound work spends a thread pool thread for nothing." None of these are enforced by the compiler the way a missing semicolon is — they're enforced by careful code review, and by knowing what to look for.

Why this lesson exists

Every mistake in this lesson uses APIs you already know how to use correctly. The danger was never "not knowing the syntax" — it's reaching for the wrong correct-looking syntax under pressure (a deadline, a quick fix, an unfamiliar codebase). Recognizing the shape of each mistake is what actually prevents it.

Why Does It Exist?

The Problem — Async Bugs Don't Look Like Bugs

A missing null check crashes loudly, right where the mistake is. Most async mistakes don't. Forgetting to await a Task doesn't throw at the call site — the method just returns before the work finishes, and everything downstream that depended on it silently gets stale or default data. Blocking with .Result doesn't fail every time — it works fine in a console app, then deadlocks the one time it's called from inside a request handler with a synchronization context. These mistakes are dangerous precisely because they're quiet.

The Need

What's needed is a way to recognize the shape of each dangerous pattern on sight — in your own code and in code review — before it ships, rather than debugging it after it's already caused a production incident.

The Solution — A Curated Tour of the Five Big Ones

The rest of this lesson is exactly that tour: five mistakes, each shown as broken code next to its fix, with the underlying "why" spelled out using concepts from earlier in this module.

Big Picture

MistakeWhat breaksWhich earlier lesson it violates
async voidExceptions can't be caught by the callerException handling in async
Forgetting to awaitMethod finishes before the work does; failures vanishasync / await, Task<T>
.Result / .Wait()Blocks a thread; can deadlockasync / await ("async all the way")
Dropping CancellationTokenDeeper work can't be stoppedCancellationToken
Unnecessary Task.RunWastes a thread pool thread for nothingSynchronous vs. asynchronous, Task & Task<T>

Notice the pattern: every single one of these traces back to a rule you already learned correctly, somewhere earlier in this module. This lesson isn't teaching new concepts — it's teaching you to recognize when an earlier rule has quietly been broken.

Mistake 1 — async void (Except Event Handlers)

An async method almost always returns Task or Task<T>. async void is legal too — but it exists almost exclusively for one narrow purpose: UI event handlers, whose signature is fixed by the framework and can't return a Task.

The mistake

public async void ProcessOrderAsync(Order order)
{
    await _repository.SaveAsync(order);
    // if SaveAsync throws, the caller
    // has no try/catch that can see it
}

The fix

public async Task ProcessOrderAsync(Order order)
{
    await _repository.SaveAsync(order);
    // now callers can await this method
    // and their try/catch actually works
}

Why it's dangerous: Recall from the exception-handling lesson that an exception thrown inside an async Task method is captured onto the returned Task, and re-thrown at the point where a caller awaits it. An async void method has no Task for anything to be captured onto — there's nothing for a caller to await in the first place. Instead, an unhandled exception from an async void method is thrown directly on whatever context started it, typically crashing the entire process — a try/catch wrapped around the call site does nothing, because by the time the exception surfaces, that calling code has often already finished running.

The one legitimate exception really is UI event handlers (private async void Button_Click(...)), because the framework's delegate signature requires void and there's no alternative — and even there, the handler's own body should wrap its work in try/catch internally, since nothing further up will catch anything for it.

Mistake 2 — Forgetting to await a Task ("Fire and Forget" by Accident)

The mistake

public async Task CheckoutAsync(Cart cart)
{
    _emailService.SendReceiptAsync(cart);
    // missing 'await' — this line doesn't wait
    // for the email to send, or even complete,
    // before moving on

    await _repository.SaveOrderAsync(cart);
}

The fix

public async Task CheckoutAsync(Cart cart)
{
    await _emailService.SendReceiptAsync(cart);
    await _repository.SaveOrderAsync(cart);
}

Why it's dangerous: Calling an async method starts it running immediately — that part happens whether or not you await the result. What await actually gives you is (a) waiting for it to actually finish before moving on, and (b) the chance for any exception it throws to surface at that point, exactly like the exception-handling lesson covered. Skip the await, and you get neither: CheckoutAsync might return — and the calling code might treat checkout as fully done — before the receipt email has even been sent. And if SendReceiptAsync throws, that exception has nowhere to go; it's attached to a Task nobody is watching, and simply disappears (in some hosts it may later surface as an "unobserved task exception" on a finalizer thread, far removed from the original call, which is its own debugging nightmare).

The compiler tries to help here: an unawaited Task-returning call usually triggers the "because this call is not awaited, execution of the current method continues before the call is completed" warning — never treat that warning as noise.

If you genuinely intend a deliberate, tracked fire-and-forget (rare, but real — e.g., kicking off a best-effort background log write that truly shouldn't block the caller), make that intent explicit and still handle its failures, rather than leaving a bare unawaited call sitting in the middle of otherwise sequential code:

// A deliberate, clearly-marked fire-and-forget — not an accident _ = Task.Run(async () => { try { await _analytics.TrackAsync(eventName); } catch (Exception ex) { _logger.LogWarning(ex, "Analytics tracking failed"); } });

Mistake 3 — Blocking on Async Code with .Result / .Wait()

The mistake

public Customer GetCustomer(int id)
{
    // blocks the calling thread; deadlock risk
    return _repository.GetCustomerAsync(id).Result;
}

The fix

public async Task<Customer> GetCustomerAsync(int id)
{
    return await _repository.GetCustomerAsync(id);
}
// let "async all the way" carry the async-ness
// up through this method's own callers too

Why it's dangerous: This is exactly the "async all the way" guidance and the deadlock mechanics from the async/await lesson, showing up again as a real habit under real pressure — usually because a surrounding method's signature is synchronous and changing it feels inconvenient. The fix is almost never "block instead" — it's to let the async propagate outward through that method's own signature, all the way to wherever the call chain can genuinely support it. Reaching for .Result/.Wait() as a shortcut around updating a signature is one of the single most common sources of hangs reported in production ASP.NET applications.

Mistake 4 — Not Passing CancellationToken Through

The mistake

public async Task<Report> BuildReportAsync(int id, CancellationToken ct)
{
    Customer customer = await _repository.GetCustomerAsync(id);
    // ct never reaches this call — cancelling the
    // outer operation can't stop this inner fetch
    List<Order> orders = await _orderService.GetOrdersAsync(id);

    return new Report(customer, orders);
}

The fix

public async Task<Report> BuildReportAsync(int id, CancellationToken ct)
{
    Customer customer = await _repository.GetCustomerAsync(id, ct);
    List<Order> orders = await _orderService.GetOrdersAsync(id, ct);

    return new Report(customer, orders);
}

Why it's dangerous: The CancellationToken lesson was explicit about this: cancellation is cooperative, and a token has to be threaded, by hand, into every layer that should be able to observe it. A method that accepts a CancellationToken parameter but then calls its own downstream dependencies without passing that token along is only cancellable in appearance — the outer caller thinks cancellation is wired up end-to-end, but the actual long-running work underneath has no way to know it was asked to stop. This is exactly the quiz scenario from that lesson (a loop that never checks its token keeps running to completion) — the same failure, just one layer removed and easier to miss in review.

Mistake 5 — Unnecessary Task.Run Around Already-Async I/O

The mistake

public async Task<Order> GetOrderAsync(int id)
{
    // GetOrderAsync is already async I/O —
    // wrapping it just burns a thread-pool
    // thread waiting for another thread
    return await Task.Run(() =>
        _dbContext.Orders.FindAsync(id).AsTask());
}

The fix

public async Task<Order> GetOrderAsync(int id)
{
    return await _dbContext.Orders.FindAsync(id);
}

Why it's dangerous: As the very first lesson in this module established, await on genuinely I/O-bound work does not necessarily consume another thread while it waits — the whole point of async I/O is that no thread is dedicated to sitting around during the wait. Task.Run, by contrast, genuinely dispatches work to a thread-pool thread — the right tool specifically for CPU-bound work you want off of, say, a UI thread. Wrapping an already-async I/O call in Task.Run gets you the worst of both: you still wait for the same I/O, but now you've also occupied a thread-pool thread for the duration, for zero benefit. Under load, this quietly eats into the very thread pool capacity that's supposed to let a server handle many concurrent requests efficiently.

Task.Run earns its place when the work is genuinely CPU-bound — a large in-memory sort, image processing, heavy computation — and you specifically want it off the current thread (a UI thread, most commonly). Reach for it deliberately for that reason, not as a reflexive wrapper around anything that returns a Task.

Analogy

A Chain of Handoffs, Each One a Chance to Drop the Baton

Think of an async call chain as a relay race, where each runner hands a baton (the work, and eventually the result) to the next. async void is a runner with no one behind them to hand the baton to — if they trip, nobody catches it. Forgetting await is handing the baton to a runner who's already left the track — it just falls on the ground. .Result/.Wait() is a runner who stops and physically grabs the next runner instead of handing off, sometimes literally tangling both of them. Dropping a CancellationToken is a race official whose "stop the race" whistle only some runners can actually hear. And wrapping already-async work in Task.Run is sending a second runner to jog alongside the first one for no reason, burning energy without moving the baton any faster.

Under the Hood

Every mistake on this page ultimately traces back to one of two internal mechanisms this module already introduced: how the compiler-generated state machine reports completion and exceptions (the async/await and exception-handling lessons), and how the thread pool decides whether a thread is actually needed at all (the synchronous-vs-asynchronous lesson). async void has no Task for the state machine to report onto. An unawaited Task has a completion the caller never observes. .Result forces a thread to sit idle waiting on that same state machine instead of being released back to the pool. A dropped CancellationToken means the state machine for the downstream call was never given the signal to check in the first place. And Task.Run around I/O asks the thread pool to hold a thread hostage for work that was never going to need one. None of these are new mechanisms — they're the same handful of moving parts from earlier lessons, just misused.

Common Confusion

1. "async void event handlers are always wrong" — not quite

They're the one legitimate exception, because UI frameworks define the event delegate signature and it's genuinely void — you can't change that. The rule isn't "never use async void," it's "never use async void except for a UI event handler, and even then, catch your own exceptions inside it."

2. "Task.Run makes things async" ≠ "await makes things async"

These two module-spanning ideas get conflated constantly. await lets a method pause without blocking a thread — it doesn't, by itself, move work anywhere. Task.Run actually moves work onto a different (thread-pool) thread — it's about where work runs, not about non-blocking waiting. Confusing the two is exactly what produces Mistake 5.

3. A compiler warning about an unawaited call is not optional reading

It's tempting to treat build warnings as background noise, especially in a codebase with hundreds of them. The specific "this call is not awaited" warning is one of the highest-signal warnings the C# compiler produces for async code — it is very often pointing at exactly Mistake 2.

Common Mistakes — Quick Recap

Instead of Do this
async void DoWorkAsync()async Task DoWorkAsync() (except real UI event handlers)
SendEmailAsync(x); (no await)await SendEmailAsync(x);
GetDataAsync().Resultawait GetDataAsync(), propagated up the signature
Calling a downstream async method without ctPass the same CancellationToken into every downstream call
Task.Run(() => SomeIoAsync())await SomeIoAsync() directly

When Should I Use It?

This lesson isn't a tool to reach for selectively — it's a checklist worth running through on every piece of async code you write or review:

Before merging any async code
Scan for these five shapes specifically — they're quick to check for and easy to miss otherwise.
Whenever a build warning mentions "not awaited"
Treat it as a real defect report, not noise to suppress.
When a server seems to run out of threads under load
Check for .Result/.Wait() and unnecessary Task.Run around I/O first — these are the usual suspects.
When "Cancel" doesn't seem to actually stop anything
Trace the CancellationToken through every layer of the call chain — a break anywhere silences it downstream.

Mental Model

Almost every async bug in this lesson comes from breaking one of two promises this module has repeated all along:

1. Keep the chain unbroken — await, all the way up; pass the CancellationToken, all the way down.
2. Match the tool to the work — await for I/O you don't own a thread for; Task.Run only for CPU work you deliberately want off the current thread.

Remember:
· async void has no Task — nothing can catch what it throws (except real UI event handlers).
· An unawaited Task's result, and its failures, are simply never seen.
· .Result / .Wait() reintroduce blocking and deadlock risk — always await instead.
· A CancellationToken only protects the calls it's actually passed into.
· Task.Run around already-async I/O wastes a thread for nothing.

Key Takeaway


Check Your Understanding

You've toured five real async pitfalls, each tying back to something earlier in this module. Let's check you can spot them — and reason about why they're dangerous, not just recognize their names.

1. A method marked async void throws an exception partway through its work. What happens to that exception from the perspective of code that called this method inside a try/catch?

Show answer

Correct: B

Why B is correct: Unlike async Task methods, where an exception is captured on the returned Task and re-thrown at an await, async void has no Task to capture it on. The exception surfaces directly, typically on whatever context started the call, bypassing the caller's try/catch entirely — which is exactly why async void is dangerous outside of UI event handlers.

Why A is incorrect: This is precisely the behavior async void breaks — a surrounding try/catch has no effect on it.

Why C is incorrect: There's no automatic logging or swallowing built into the runtime for this — the exception genuinely propagates in a way the caller can't intercept normally.

Why D is incorrect: Nothing about async void suppresses the exception into a default value — it still throws, just not somewhere the caller can catch it.

Reinforcement: async void severs the normal exception-propagation path that async Task relies on — use async Task everywhere except real UI event handlers.

2. In a method, a line calls an async Task-returning method but doesn't await it, and execution continues to the next line immediately. What is the most accurate description of what happened?

Show answer

Correct: B

Why B is correct: Calling an async method starts its work immediately regardless of whether you await it. What await actually provides is waiting for completion and giving any exception a place to surface. Without it, execution just moves on, and a later failure in that unawaited Task is never observed by this code.

Why A is incorrect: The work does start — that part doesn't require await. It's the waiting and exception observation that are missing.

Why C is incorrect: The compiler never inserts an await for you — it only warns that the call isn't awaited, leaving the actual behavior unchanged.

Why D is incorrect: There's no synchronous fallback — the call still runs asynchronously; it's simply not being waited on.

Reinforcement: An unawaited Task is still running, just unobserved — which is exactly what makes its eventual failures so easy to miss.

3. A developer writes a synchronous method that calls someTask.Result to "just get the value" from an async repository call. Why is this considered dangerous rather than merely inelegant?

Show answer

Correct: B

Why B is correct: This is exactly the "async all the way" and deadlock danger from the async/await lesson resurfacing as a practical habit. Blocking with .Result wastes a thread while it waits, and in a context with a synchronization context, it can deadlock — the blocked thread is exactly the thread the awaited continuation needs to resume on.

Why A is incorrect: .Result doesn't inherently throw — it can work fine in many cases, which is part of what makes the deadlock risk so easy to miss until it actually happens.

Why C is incorrect: .Result returns the real result value (or re-throws the underlying exception, wrapped) — it doesn't silently substitute null.

Why D is incorrect: This code, using Task<T>.Result on a repository call returning data, is completely valid and compiles fine — the problem is behavioral, not a compile error.

Reinforcement: The danger of .Result/.Wait() is inconsistent and context-dependent, which is exactly why it's easy to miss in casual testing and only surfaces under real production conditions.

4. A method BuildReportAsync(int id, CancellationToken ct) calls two downstream async methods but forgets to pass ct into either call. The caller cancels the operation via the token's source. What actually happens?

Show answer

Correct: B

Why B is correct: Cancellation is cooperative, and a token has to be explicitly passed into every layer that should observe it — this is exactly what the CancellationToken lesson established. Since neither downstream call receives ct, neither one has any way to notice cancellation was requested; both continue running as if nothing happened.

Why A is incorrect: Nothing about CancellationToken propagates automatically through a call stack — it must be passed as a parameter at every layer, by hand.

Why C is incorrect: An unused parameter like this produces, at most, a compiler warning in some configurations — never a hard compile error.

Why D is incorrect: Since neither call receives the token, there's no reason the first would behave differently from the second — both run to completion regardless of cancellation.

Reinforcement: Accepting a CancellationToken parameter is not the same as making your operation cancellable — every downstream call that should honor it needs the token explicitly passed in.

5. A developer wraps an EF Core database call — `await Task.Run(() => _dbContext.Orders.FindAsync(id).AsTask())` — believing it makes the call "more async." What's the actual effect?

Show answer

Correct: B

Why B is correct: FindAsync is already asynchronous I/O — awaiting it directly doesn't tie up a thread while waiting on the database. Wrapping it in Task.Run dispatches it to a thread-pool thread that then just sits there awaiting the same I/O — a wasted thread for zero benefit, exactly as this lesson described.

Why A is incorrect: Two threads don't make a single database round-trip finish any faster — the I/O itself is the bottleneck, not thread availability.

Why C is incorrect: Task.Run genuinely dispatches the work to the thread pool — it's not a no-op, which is exactly why it has a real (negative) cost here.

Why D is incorrect: The operation remains I/O-bound regardless of the wrapping — Task.Run doesn't change the fundamental nature of the work, and I/O-bound work generally doesn't benefit from being forced onto the thread pool this way.

Reinforcement: await is for I/O-bound waiting that doesn't need a dedicated thread; Task.Run is for CPU-bound work that genuinely needs one. Mixing them up wastes thread pool capacity for nothing.

That completes Part VII — Async Programming. You've gone from the basic difference between synchronous and asynchronous code all the way through Task, async/await, cancellation, concurrent coordination with WhenAll/WhenAny, exception handling, async streams, and now the mistakes to watch for in all of it. You're equipped to write async C# code that's both correct and genuinely non-blocking.


dotnetmadeeasy.com — Learn C# and .NET, the right way.