Skip to main content

📝 Lesson 3.3: Async Performance and Pitfalls

Async is easy to write and easy to get subtly wrong. This lesson covers the details that separate correct-and-fast async from code that allocates too much, deadlocks, or silently swallows errors — ValueTask, ConfigureAwait, and the classic traps.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain the hidden costs of an async method
  • Use ValueTask<T> to reduce allocations on hot paths (and its rules)
  • Explain what ConfigureAwait(false) does and when to use it
  • Avoid the sync-over-async deadlock and async void
  • Recognize other common async mistakes

Estimated Time: 60 minutes

Project: Diagnose and fix a set of flawed async methods.

In This Lesson

The Cost of Async

async/await is not free. When you mark a method async, the compiler generates a state machine (like iterators, Lesson 1.3) to manage its pauses and resumes. If the method actually suspends at an await, that state machine and the returned Task are typically allocated on the heap.

💡 Usually this cost is irrelevant

For I/O-bound work — a web request, a file read — the operation takes milliseconds, so a tiny allocation is noise. Don't micro-optimize normal async code. The techniques here matter for hot paths: methods called millions of times, often in libraries and high-throughput servers.

One important optimization the runtime already does: awaiting an already-completed Task doesn't allocate or suspend — it continues synchronously. This is why a method that often has its result ready (a cache hit) can be much cheaper than it looks — and it's the motivation for ValueTask.

ValueTask

A Task<T> is a reference type, so returning one allocates an object. ValueTask<T> is a struct (value type) that can wrap either an already-available result or a real task — avoiding the allocation when the result is ready synchronously.

// Hot path: a cache that usually has the value already
private readonly Dictionary<int, string> _cache = new();

public ValueTask<string> GetAsync(int id)
{
    if (_cache.TryGetValue(id, out string? cached))
    {
        return new ValueTask<string>(cached);   // no allocation — result is ready
    }
    return new ValueTask<string>(LoadAsync(id));   // falls back to a real Task
}

private async Task<string> LoadAsync(int id)
{
    string value = await FetchFromDbAsync(id);
    _cache[id] = value;
    return value;
}

⚠️ ValueTask has strict rules

Because it may wrap pooled or transient state, a ValueTask:

  • must be awaited at most once — never await it twice;
  • must not be awaited concurrently or stored for later;
  • if you need to do those things, convert it once with .AsTask().

A plain Task has none of these restrictions.

✅ When to use it

Use ValueTask<T> only for hot-path methods that frequently complete synchronously (caches, buffered reads) where profiling shows allocations matter. Otherwise, default to Task — it's simpler and its rules are forgiving. Premature ValueTask everywhere adds risk for no benefit.

ConfigureAwait

When you await, by default the runtime captures the current synchronization context and resumes the continuation on it. In a UI app that context is the UI thread (so you can safely touch controls after an await); in classic ASP.NET it was the request context.

📖 What ConfigureAwait(false) does

It says "I don't need to resume on the captured context — continue on any available thread pool thread." This skips the cost of marshaling back and, crucially, avoids a deadlock trap (next section).

// In library code: don't capture the caller's context
public async Task<string> LoadAsync()
{
    string data = await _client.GetStringAsync(url).ConfigureAwait(false);
    return Process(data);   // resumes on a pool thread — fine, no UI access needed
}

💡 Where it matters (and where it doesn't)

  • Library code: use ConfigureAwait(false) on every await — libraries shouldn't assume or depend on the caller's context, and it improves performance.
  • UI apps (WPF/WinForms/MAUI): in code that touches UI after the await, don't use it — you need to resume on the UI thread.
  • ASP.NET Core & console apps: there's no special sync context, so ConfigureAwait(false) is largely a no-op — harmless, and unnecessary in app code.

The Sync-Over-Async Deadlock

The most infamous async bug: calling .Result or .Wait() on an async method from a context that has a single-threaded synchronization context (classic UI or ASP.NET). Here's the trap:

graph TD A["UI thread calls task.Result"] --> B["UI thread BLOCKS waiting"] C["async method finishes its await"] --> D["needs the UI thread
to run its continuation"] B --> E["UI thread is busy blocking..."] D --> E E --> F["Deadlock — both wait forever"] style A fill:#eff6ff,stroke:#3b82f6,stroke-width:2px style F fill:#ffebee,stroke:#c62828,stroke-width:2px

The blocked thread is exactly the thread the awaited continuation needs to resume on — so neither can proceed. Two fixes:

  • The real fix — go async all the way: await instead of blocking. Never call .Result/.Wait().
  • Mitigation in library code: ConfigureAwait(false) means the continuation doesn't need the captured thread, breaking the cycle.
// ❌ Can deadlock in a UI/classic-ASP.NET context
string data = LoadAsync().Result;

// ✅ Await it — no blocking, no deadlock
string data = await LoadAsync();

⚠️ It may "work" in a console app and fail in production

Console apps and ASP.NET Core have no single-threaded context, so .Result may appear to work there — then deadlock the moment the same code runs under a UI or classic ASP.NET context. Don't rely on it working; just await.

async void and Fire-and-Forget

An async method should return Task, not void (you saw this in Intermediate — here's the deeper why). With async void:

  • The caller can't await it — there's no Task to await, so you can't know when it finishes or that it succeeded.
  • Exceptions can't be caught by the caller — they're raised on the sync context and typically crash the process.
// ❌ async void — exceptions escape and can crash the app
async void SaveData() { await File.WriteAllTextAsync(path, data); }

// ✅ async Task — awaitable, catchable
async Task SaveDataAsync() { await File.WriteAllTextAsync(path, data); }

⚠️ The only acceptable async void: event handlers

UI event handlers must match a void-returning delegate signature, so async void is unavoidable there — and you must try/catch inside them. Everywhere else, return Task.

💡 "Fire-and-forget" needs care

Starting a task without awaiting it (_ = DoWorkAsync();) discards its result and its exceptions — an unobserved failure can vanish silently. If you truly must fire-and-forget, wrap the body in try/catch and log failures, or use a dedicated helper. Prefer awaiting whenever you can.

More Pitfalls

⚠️ Forgetting to await

Calling an async method without await starts it but doesn't wait — the code moves on before it finishes, and exceptions are lost. The compiler warns (CS4014); heed it. If you meant to run several concurrently, collect the tasks and await Task.WhenAll (Intermediate).

⚠️ await in a loop when you meant concurrency

Awaiting inside a loop runs items sequentially. For independent operations, start them all, then await together:

// Sequential (slow) — each awaited before the next starts
foreach (var url in urls) results.Add(await FetchAsync(url));

// Concurrent (fast) — start all, then await
var tasks = urls.Select(FetchAsync);
var results = await Task.WhenAll(tasks);

💡 Async elision (returning the task directly)

If a method just returns another async call's result without needing to do anything after the await, you can drop async/await and return the Task directly — skipping the state machine:

// Slightly cheaper — no extra async state machine
public Task<string> GetAsync() => _client.GetStringAsync(url);

But keep async/await if you need a try/catch, using, or work after the await — eliding those would change behavior.

⚠️ Don't block the thread inside async

Calling synchronous blocking APIs (Thread.Sleep, synchronous I/O, .Result) inside an async method ties up a pool thread and defeats the purpose. Use the async equivalents (await Task.Delay, await ...Async(...)).

Exercise & Quiz

🏋️ Exercise: Fix the Flawed Async Code

Objective: Identify and correct four common async mistakes.

Instructions — rewrite each snippet correctly:

  1. Blocking: string s = LoadAsync().Result; in an async method.
  2. async void: async void ProcessAsync() { await ...; } for a non-event-handler method.
  3. Sequential loop: fetching 3 independent URLs by awaiting each in a foreach.
  4. Fire-and-forget: SaveAsync(); with no await and no error handling.

Starter Code:

// 1) blocking
string s = LoadAsync().Result;

// 2) async void
async void ProcessAsync() { await Task.Delay(100); }

// 3) sequential loop
var results = new List<string>();
foreach (var url in urls) results.Add(await FetchAsync(url));

// 4) fire and forget
SaveAsync();
💡 Hint

(1) await LoadAsync();. (2) return Task and rename with Async. (3) start all with Select, then await Task.WhenAll. (4) either await SaveAsync(); or, if truly fire-and-forget, wrap in try/catch and log.

✅ Solution
// 1) await instead of blocking
string s = await LoadAsync();

// 2) return Task, name it *Async
async Task ProcessAsync() { await Task.Delay(100); }

// 3) run concurrently
string[] results = await Task.WhenAll(urls.Select(FetchAsync));

// 4) either await it...
await SaveAsync();

// ...or, if it must be fire-and-forget, observe failures:
_ = Task.Run(async () =>
{
    try { await SaveAsync(); }
    catch (Exception ex) { Log(ex); }
});

Bonus: in a library version of these methods, add .ConfigureAwait(false) to each await.

🎯 Quick Quiz

Question 1: When is ValueTask<T> worth using instead of Task<T>?

Question 2: What causes the classic sync-over-async deadlock?

Question 3: Why avoid async void (outside event handlers)?

Summary

🎉 Key Takeaways

  • async generates a state machine and (when it suspends) allocates a Task — usually negligible; optimize only hot paths.
  • ValueTask<T> avoids allocation when a result is often ready synchronously — but await it once, don't store or reuse it. Default to Task.
  • ConfigureAwait(false) skips capturing the sync context — use it throughout library code; unnecessary in ASP.NET Core/console; avoid where you need the UI thread.
  • Never block with .Result/.Wait() — it risks the sync-over-async deadlock. Go async all the way.
  • Avoid async void (except event handlers); don't forget to await; run independent work with Task.WhenAll, not a sequential loop.

📚 Additional Resources

🚀 What's Next?

That completes Module 3 — you write async that's correct, controllable, and fast! In Module 4, we go deeper into performance itself. First: Lesson 4.1: The Memory Model — value vs reference types, stack vs heap, and boxing.

🎉 Module 3 complete!

You've mastered advanced asynchrony end to end. Next, we get serious about memory and performance.