Previously, in Part 2, we tackled the C# fundamentals every technical round opens with. Now we reach the topic interviewers love most: async/await. It's beloved because it separates people who've typed the keywords from people who understand the machinery — and because it comes with a legendary gotcha question about deadlocks. Let's make sure you're in the second group.
"What does await actually do?"
Why they ask it: Almost everyone can use await. This question checks your mental model, because a wrong model ("await waits on a background thread") leads directly to the bugs the later questions are about.
A strong answer: await does not block a thread. When the
awaited task isn't finished yet, the method returns control to its caller
right there, and registers the rest of the method as a continuation to run when the task
completes. The compiler makes this possible by rewriting your method into a
state machine: each await is a bookmark where the method can pause and
later resume, with all its local variables preserved.
async Task<string> GetGreetingAsync()
{
Console.WriteLine("Before await");
await Task.Delay(2000); // method RETURNS to caller here
Console.WriteLine("After await"); // ...and resumes here, 2s later
return "hello";
}
During those two seconds, no thread is sitting there waiting — the thread goes off to do other work. That's the entire point: threads are expensive, and await lets a handful of them serve thousands of in-flight operations. If the metaphor helps, I've explained this with a coffee-shop barista in async/await explained simply — the barista starts your espresso and serves the next customer instead of staring at the machine.
Try it: Paste the method above into your console app, call it without awaiting (var t = GetGreetingAsync();), and print "Caller is free!" on the next line before await t. The output order — Before await, Caller is free!, After await — is the whole model in three lines.
Follow-ups to expect:
- "What if the task is already complete when you await it?" — The method just continues synchronously; no pause, no continuation scheduling.
- "What does ConfigureAwait(false) change?" — The continuation no longer resumes on the captured context; standard in library code.
- "Where do exceptions from an awaited task go?" — They're captured on the task and re-thrown at the await, so try/catch works naturally.
Red flag: "Await runs the method on a background thread." Await creates no threads and blocks no threads — confusing it with Task.Run is the classic wrong model.
"Why is async void dangerous?"
Why they ask it: It's a cheap, reliable probe. Anyone who has actually shipped async code has been warned about async void; anyone who hasn't will shrug.
A strong answer: An async void method gives its caller
nothing to hold on to. Two things follow. First, exceptions become unobservable:
with async Task, an exception is captured on the returned task and surfaces
when you await it — but with async void there is no task, so the exception is re-thrown
on the synchronization context and typically crashes the process. Second,
completion is untrackable: callers can't await it, tests can't wait for
it, and shutdown code can't know it's still running.
async void Dangerous()
{
await Task.Delay(100);
throw new InvalidOperationException("nobody can catch me");
}
async Task Safe()
{
await Task.Delay(100);
throw new InvalidOperationException("caught at the await site");
}
The one legitimate use — and saying this is what marks a strong answer — is
event handlers, like a button click. Event signatures return void, so
async void is the only way to await inside them. Everywhere else:
async Task.
Try it: In your console app, wrap a call to each method above in try/catch and observe: the exception from Safe (awaited) is caught; the one from Dangerous never hits your catch block. Seeing an uncatchable exception once is worth ten explanations.
Follow-ups to expect:
- "So how do you handle errors in an async void event handler?" — Try/catch inside the handler itself, since nothing outside can.
- "How would you unit test async code?" — Return Task from the method and await it in the test; async void is untestable by design.
- "What's async Task vs async Task<T>?" — Same machinery; one completes with a value, the other just completes.
Red flag: "async void is fine, I use it all the time" — or the opposite absolutism, banning it even for event handlers, which suggests a rule memorized without the reason.
"Explain how .Result or .Wait() can deadlock."
Why they ask it: This is the deadlock special — the most famous async question in .NET interviewing. It requires combining two concepts (blocking and context capture) into one causal story, which makes it a superb reasoning-aloud test.
A strong answer: It's sync-over-async plus a captured context waiting on
itself. Step by step: in a UI app (or classic ASP.NET), there's a
synchronization context — a rule that continuations resume on one special
thread. When you await, the continuation is scheduled back onto that context. Now suppose
that special thread calls .Result: it blocks until the task
completes. But the task can't complete until its continuation runs on that very
thread — which is blocked. Each side is waiting for the other. Deadlock.
// Classic UI-context repro (button click handler):
public string GetData()
{
return GetDataAsync().Result; // UI thread blocks HERE...
}
private async Task<string> GetDataAsync()
{
await Task.Delay(1000); // ...continuation needs the UI thread
return "done"; // never reached: both sides waiting
}
The current-day nuance that impresses: ASP.NET Core removed the synchronization context, so this exact deadlock is much rarer there — continuations just resume on a thread-pool thread. But interviewers still ask, because the underlying sin (sync-over-async) still wastes threads and still deadlocks in UI frameworks like WinForms, WPF, and MAUI. The cure is boring and correct: await all the way up the call chain.
Try it: Create a scratch WinForms app, put the two methods above behind a button, and click it. The window freezes forever — a deadlock you caused on purpose. Then change .Result to await and click again. That before/after is a war story for your behavioral round.
Follow-ups to expect:
- "Why doesn't this deadlock in a console app?" — No synchronization context; continuations run on the thread pool, so the blocked thread isn't needed.
- "How does ConfigureAwait(false) prevent it?" — The continuation skips the captured context, so it doesn't need the blocked thread — a mitigation, not a license to block.
- "When is sync-over-async ever acceptable?" — Rare edges like Main before async Main existed, or legacy interfaces you can't change — and you isolate it carefully.
Red flag: "Just sprinkle ConfigureAwait(false) everywhere" as the fix. It treats the symptom, breaks down the moment one call in the chain forgets it, and dodges the real answer: don't block on async code.
"Task vs Thread — and when do you actually need a Thread?"
Why they ask it: It surfaces whether your concurrency model is from this
decade. Candidates who reach for new Thread(...) are usually pattern-matching
on tutorials from 2010.
A strong answer: A Thread is a worker — an actual OS thread
with its own stack, expensive to create. A Task is a promise of
work — a handle representing something that will complete, which may run on a
pool thread, or may involve no thread at all (awaiting a network response consumes zero
threads while waiting). Tasks compose: you can await them, chain them, combine them with
Task.WhenAll, cancel them with a CancellationToken. Threads
compose with none of that.
So the working rules: for I/O-bound work, use async/await — no thread needed. For
CPU-bound work you want off the current thread, use Task.Run and let the
thread pool manage workers. Raw threads are almost never needed today; the honest
exceptions are niche — a long-lived dedicated worker you want isolated from the pool, or
special thread configuration like an STA thread for COM interop. Saying "almost never,
and here's the rare exception" is exactly the calibrated answer interviewers want.
Try it: Start 10,000 Task.Delay(1000) tasks and await them with Task.WhenAll, printing ThreadPool.ThreadCount or just timing it. Ten thousand concurrent waits, about one second, a handful of threads. Now imagine 10,000 new Thread objects — that contrast is the answer.
Follow-ups to expect:
- "What does Task.Run actually do?" — Queues your delegate to the thread pool and hands you a Task representing its completion.
- "How do you cancel a long-running task?" — Pass a CancellationToken and have the work check it or pass it on; cancellation is cooperative, not forced.
- "Task.WhenAll vs awaiting in a loop?" — WhenAll runs the operations concurrently; a loop of awaits runs them one at a time.
Red flag: "Task.Run makes I/O faster." Wrapping an async call in Task.Run just burns a pool thread to wait; Task.Run is for CPU-bound work, not a general go-faster button.
Where to next
The through-line of this round: await releases threads, tasks are promises, and blocking on either is how you summon deadlocks. If you want the gentler narrative version of the model, keep the coffee-shop explanation in your back pocket.
Next, the other guaranteed topic pair of the technical round: Part 4 — Collections and LINQ Questions.