async/await in .NET Interviews: Deadlocks, Thread Pool Starvation, and What Interviewers Are Really Asking
Almost every mid-to-senior .NET interview asks about async/await. Almost every candidate answers the wrong question — the definition instead of the trade-off.
The question usually arrives in one of three shapes:
- “What is async/await and how is it different from using a
Thread?” - “What’s wrong with calling
.Resultor.Wait()on a task?” - “When would you use
Task.Run?”
They sound like three questions. They are one question, and it is this: have you operated an async system under load, and do you know what it costs when you block the wrong thread?
The student answer
“async/await is for I/O-bound work and threads are for CPU-bound work.
awaitdoesn’t block the thread, it frees it up.Task.Runschedules work on the thread pool. You shouldn’t block on async code because it can deadlock.”
Every sentence is true. None of it demonstrates that you have been on call for a service that fell over because of this. Interviewers hear this answer ten times a week and it moves nothing.
What is actually happening, so the trade-off makes sense
When you await a genuinely asynchronous operation — a database round trip, an HTTP call, a queue read — the compiler has rewritten your method as a state machine. At the await point, the method returns. The thread that was running it goes back to the pool and does other work. When the I/O completes, a continuation is scheduled to finish the method, possibly on a different thread.
The entire value proposition is that a thread is not sitting still while something slow happens elsewhere. On a web server, threads are the scarce resource. A machine that can hold tens of thousands of open connections has a thread pool of maybe a few dozen threads to start. Async is how a small number of threads serve a large number of in-flight requests.
Blocking breaks that. task.Result, task.Wait(), and task.GetAwaiter().GetResult() all stop the current thread until the task finishes. Now the thread is doing exactly what async was supposed to prevent: standing still, holding a slot, waiting.
Failure mode one: the classic deadlock
In older frameworks — ASP.NET on .NET Framework, WPF, WinForms — there is a SynchronizationContext that forces continuations back onto a specific thread (the request thread, the UI thread). The deadlock is mechanical:
- You call
SomeAsyncMethod().Resulton the request thread. - That thread is now blocked, waiting for the task.
- Inside, the method finishes its
awaitand tries to schedule its continuation back onto… the request thread. - Which is blocked, waiting for the continuation. Neither side moves.
This is the deadlock interviewers expect you to name. Knowing it exists is table stakes. Knowing that ConfigureAwait(false) avoids it — by telling the continuation it does not need the original context — is the next layer. Knowing that ConfigureAwait(false) is a workaround for a mistake you should not be making is the layer that sounds senior.
Failure mode two: thread pool starvation (the one people miss)
ASP.NET Core removed the request SynchronizationContext. Candidates often conclude “so blocking is fine now.” It is not. The failure just changes shape from a hard lock to a slow collapse.
Here is a scenario I have had to trace. A background component called a third-party client library that only exposed synchronous methods, so someone wrapped an async pipeline around it and blocked internally with .GetAwaiter().GetResult(). Fine in development. Under production traffic, every one of those calls parked a thread pool thread for the duration of a network call. As load rose, the pool ran out of free threads. The pool’s injection logic adds new threads slowly — on the order of one or two per second — so it could not keep up with the arrival rate.
The symptom was not an exception. It was latency: requests that normally returned in 80 ms started taking 3–10 seconds, because they were sitting in a queue waiting for a thread that was busy blocking on I/O. CPU was low. Memory was fine. Every dashboard looked healthy except the one that mattered. The fix was to use the library’s async API where it existed and to move the genuinely sync-only work off the request path entirely.
Task.Run is not a synonym for “make it async”
Task.Run pushes a delegate onto the thread pool. That is the right tool for CPU-bound work you want off the current thread — image processing, a heavy calculation, parsing a large file. It is the wrong tool for I/O. Wrapping await httpClient.GetAsync(...) in Task.Run does not make the network faster; it just burns a second thread to wait for the first one. On a web server, Task.Run around I/O is usually a small self-inflicted wound.
The engineer answer
“async/await isn’t really about I/O versus CPU — it’s about not holding a thread while you wait on something slow. Threads are the scarce resource on a server. The cost of getting it wrong is either a hard deadlock, in frameworks with a SynchronizationContext, or thread pool starvation in ASP.NET Core — which is worse to diagnose because it shows up as creeping latency with healthy CPU and memory. I’ve traced exactly that: a sync-over-async call to a third-party library that parked pool threads under load until requests started queuing for seconds. The rule I follow is: async all the way down, never block on a task on a request path, and only reach for
Task.Runwhen the work is genuinely CPU-bound.ConfigureAwait(false)matters in library code; in an ASP.NET Core app it mostly doesn’t, because there’s no context to capture.”
Same knowledge as the student answer. Completely different signal — because it is anchored in a consequence, a diagnosis, and a rule you now follow.
Follow-ups interviewers use to check if the answer was memorised
- “Why is thread pool starvation hard to spot?” Low CPU, low memory, rising latency, requests queued before they reach your code.
- “What’s
async voidfor?” Event handlers only. Anywhere else, exceptions escape onto the synchronization context and can take the process down, and callers can’t await completion. - “Does async make a single request faster?” No. It improves throughput and scalability under concurrency. A lone request may even be marginally slower.
- “How would you expose an async method to a sync-only caller?” Ideally you don’t. If forced, isolate it, understand you’re paying a thread, and keep it off hot paths.
Async is mainly about what happens while you wait
The distinction that matters to me is not “async versus threads.” It is whether the operation spends time using the CPU or waiting for something outside the process. Database calls, HTTP requests and file I/O spend a lot of their lifetime waiting. Asynchronous APIs let the thread return to the pool during that wait instead of occupying it for no useful work.
That is why wrapping naturally asynchronous I/O in Task.Run is usually a step backward in a web application. It schedules more thread-pool work without making the underlying I/O more asynchronous.
Follow the request through
public async Task<IActionResult> GetOrder(int id)
{
var order = await _db.Orders
.AsNoTracking()
.SingleOrDefaultAsync(x => x.Id == id);
return order is null ? NotFound() : Ok(order);
}The useful property here is “async all the way.” The controller does not block on the task, and the data-access call uses an asynchronous database API. Replacing the await with .Result breaks that chain and makes the request hold a thread while it waits.
Three debugging questions
- Is the slow operation CPU-bound or I/O-bound?
- Is anything synchronously blocking on a
Taskwith.Result,.Wait()orGetAwaiter().GetResult()? - Did we create unbounded parallelism and flood the database, API or thread pool?
Async code can improve scalability, but it does not remove capacity limits. If 5,000 requests all hit the same database at once, the bottleneck may simply move downstream. Concurrency still needs a budget.
This post is one worked example of a format I use for every .NET interview topic: definition → cost → real scenario → decision. I turned the full set — CLR and GC, async internals, EF Core, ASP.NET Core, system design — into an A4-printable prep system called .NET Job Interview OS, with weak-versus-strong answers side by side for each question.
The takeaway
When an interviewer asks about async/await, they are not asking you to recite the state machine. They are asking whether you understand that threads are scarce, that blocking spends them, and that the bill arrives as a latency cliff on a Tuesday afternoon. Answer that, with one real trace attached, and you have answered the question they meant to ask.