How does async/await work, and why use it in a web API?
Asked at Deloitte, IBM, Infosys, Wipro, and TCS, and by .NET interviewers on r/dotnet.
Say this first · about 30 seconds
In a web API, async and await are about server throughput and scalability, not making a single request run faster.
When an action awaits an I/O call like a database query or external service, it yields the thread back to the thread pool so other incoming requests can use it.
Under the hood, the compiler rewrites the method into a state machine that pauses execution at incomplete awaits and saves local state.
It hooks a continuation callback to the pending task and returns to the caller, so zero threads sit idle during network waits.
When the operating system signals that the I/O data has arrived, the thread pool picks up the continuation and resumes execution from that exact step.
Make it yours · UDC Finance · Commercial vehicle lending portal
At UDC Finance, commercial loan approval requires valuation checks against two vehicle valuation APIs.
An older shared helper invoked HTTP calls synchronously with .Result, causing thread-pool starvation during NZ morning peak.
I refactored the pipeline to async end to end and orchestrated the checks concurrently using with cancellation tokens.
Peak p95 latency dropped from 8.4 seconds to 410 ms, and ThreadPool queue length stayed flat at zero.
The code, simplified
The concurrent valuation service at UDC Finance, simplified.
UDC.sln · where this file sits
src/Services/
Fix later in your repoInvented example: production UDC code wraps both HTTP clients in Polly resilient rate-limiting pipelines.
Say it out loud, then check
Show the 30-second answer
In a web API, async and await are about server throughput and scalability, not making a single request run faster.
When an action awaits an I/O call like a database query or external service, it yields the thread back to the thread pool so other incoming requests can use it.
Under the hood, the compiler rewrites the method into a state machine that pauses execution at incomplete awaits and saves local state.
It hooks a continuation callback to the pending task and returns to the caller, so zero threads sit idle during network waits.
When the operating system signals that the I/O data has arrived, the thread pool picks up the continuation and resumes execution from that exact step.
Check your answer: tick what you said
You said 0 of 8
Then add your example: UDC Finance · Commercial vehicle lending portal
At UDC Finance, commercial loan approval requires valuation checks against two vehicle valuation APIs.
An older shared helper invoked HTTP calls synchronously with .Result, causing thread-pool starvation during NZ morning peak.
I refactored the pipeline to async end to end and orchestrated the checks concurrently using with cancellation tokens.
Peak p95 latency dropped from 8.4 seconds to 410 ms, and ThreadPool queue length stayed flat at zero.
See it
HTTP RequestGET /api/loans/42
Kestrel Web APIASP.NET Core server
Thread 1worker thread
Thread 2pool worker
DatabaseAzure SQL server
1/7Request arrives. Thread 1 picks it up from the pool and starts executing the controller.The controller invokes FindLoanAsync on LoanService, passing the cancellation token.LoanService delegates to LoanRepository, which prepares the asynchronous EF Core query.The repository sends the query to . An incomplete Task is returned immediately.await pauses execution. State machine saves state. Thread 1 is released back to pool!Database finishes query. The thread pool assigns any available (Thread 2) to resume.Thread 2 maps the entity to DTO and returns the HTTP 200 response back to the client.
In plain words
Think of a busy restaurant with five waiters. If each waiter takes an order to the kitchen and stands still staring at the oven for ten minutes until the food is cooked, no other customers can be seated. That is synchronous blocking.
Async and await is the waiter handing the ticket to the kitchen, taking an order slip, and immediately going back to greet new tables. When the bell rings, any free waiter picks up the tray and delivers the food.
In your API, (ASP.NET Core's built-in web server) is the dining room, the is the team of waiters, and the database or third-party service is the kitchen.
The keyword does not block the waiter; it pauses your recipe, writes down what step you reached on an index card (the compiler's state machine struct), and frees the to serve other people.
The senior answer · about 2 minutes
Why use it in a web API
I explain async in ASP.NET Core in terms of server throughput, not request latency.
Kestrel serves requests using the .NET . If an endpoint does blocking I/O, that thread sits idle waiting on network packets or database responses while doing zero CPU work.
Under peak load, hundreds of concurrent blocked calls exhaust the pool. Because the thread pool only injects one or two threads per second once minimum threads are passed, incoming requests queue up and the whole API stalls.
Async releases that thread immediately upon awaiting I/O, allowing a handful of threads to serve thousands of concurrent requests.
How the compiler state machine works
Under the hood, the compiler rewrites an async method into a private struct implementing .
Local variables and parameters are hoisted into fields on the struct so their state survives across suspension points.
The method's entry point creates an and calls . If the awaited is already completed, execution continues synchronously on the fast path with zero allocations.
If the task is incomplete, the builder moves the struct to the heap, registers as the continuation callback via the awaiter, and returns the builder's Task to the caller.
When the operating system I/O completion port or epoll fires, the thread pool invokes , jumping straight back into the switch statement at the next state index.
ASP.NET Core and SynchronizationContext
In older .NET Framework and desktop apps, forced continuations back onto a specific UI thread or request thread, which caused deadlocks with sync-over-async.
ASP.NET Core deliberately has no . Every continuation resumes on whatever thread-pool thread is free.
That means ConfigureAwait(false) is not needed in controller or service application code, though I still use it in shared Class Libraries so they remain safe if consumed by a desktop client.
Production patterns and pitfalls
I enforce async all the way down: controller to service to repository. A single sync-over-async call like .Result or .Wait() inside the chain destroys the scalability benefit.
I ban in code reviews and analyzers because unhandled exceptions cannot be caught and terminate the process.
I always pass down to EF Core and so that when Kestrel detects a client disconnection through , expensive backend work stops immediately.
For hot paths where data is often cached, I consider to avoid allocating Task objects on the heap.
Inside the compiler: the async state machine
When you write async and await, the C# compiler transforms your method into a state machine struct implementing IAsyncStateMachine.
GetLoanAsyncmethod entry
IAsyncStateMachinestate counter: -1
MethodBuilderAsyncTaskMethodBuilder
OS Kernel / IOCPsocket I/O completion
1/6Method called. The struct initializes with state -1. MoveNext begins synchronously.Execution reaches await. It checks task.IsCompleted. If true, it runs synchronously.Task incomplete. State updates to 0. Local variables are saved into struct fields.Builder hooks MoveNext via awaiter.UnsafeOnCompleted and returns incomplete Task.Hardware I/O finishes. OS kernel posts notification to the I/O completion port.ThreadPool invokes MoveNext. Switch dispatcher jumps to state 0, unpacks result, and completes.
The story
Before
In synchronous code, a thread pool thread is assigned to the request when it enters (ASP.NET Core's web server). When that thread calls a database query or an external API, it sits blocked doing nothing until the network packets return. That thread cannot do any other work.
Why it had to change
Under heavy traffic, hundreds of requests hit blocking I/O simultaneously. The thread pool runs out of available threads, known as thread-pool starvation. New requests pile up in the thread pool queue, and latency surges across the entire server, even on simple in-memory endpoints.
How it works now
With and , the method is split at the suspension point. When your code awaits an incomplete , the calling thread is returned directly to the thread pool to process other incoming traffic. No thread is held while waiting for network or disk I/O.
What happens next
When the database driver receives the response packets from the socket, the operating system notifies the .NET runtime. The runtime queues a continuation (the callback that executes the remaining code) to the thread pool, and any available thread picks up execution right after the await statement.
What we gained
Server throughput (the number of concurrent requests served per second) multiplies by orders of magnitude. A small pool of 10 to 20 threads can easily handle 5,000 concurrent I/O-bound requests without exhausting server memory or stalling response times.
In the lending API
The lines that matter.
Only the key lines are shown; Deep walks the whole file.
Lending.sln · where this file sits
src/Api/Controllers/Servicing/
⋯ 2 lines in Deep⋯ 4 lines in Deep
LoansController.cs · line 11 · 1 of 3Takes CancellationToken from HttpContext.RequestAborted to cancel queries if client disconnects.
The code, file by file
In ASP.NET Core, routing directs an incoming HTTP request to LoansController.GetByIdAsync, injecting services from DI (ILoanService, ILoanRepository, ServicingDbContext). Stepping through shows the execution flow down the call stack and back up.
Lending.sln · where these 3 files sit
src/
Api/Controllers/Servicing/
Application/Servicing/Loans/
Infrastructure/Servicing/
Diagram · step 1Request arrives. Thread 1 picks it up from the pool and starts executing the controller.
HTTP RequestGET /api/loans/42
Kestrel Web APIASP.NET Core server
Thread 1worker thread
Thread 2pool worker
DatabaseAzure SQL server
LoansController.cs · line 11 · 1 of 3Takes CancellationToken from HttpContext.RequestAborted to cancel queries if client disconnects.
What they ask next
The follow-ups interviews really ask. Answer out loud, then tap to check.
What does ConfigureAwait(false) do, and do we need it in ASP.NET Core?
In UI apps or legacy ASP.NET with a SynchronizationContext, ConfigureAwait(false) tells the runtime not to marshal the continuation back onto the original context. In ASP.NET Core, there is no SynchronizationContext, so continuations always resume on an arbitrary thread pool thread. Thus, ConfigureAwait(false) is not required in application code, though it remains best practice in shared libraries.
What is the difference between Task and ValueTask, and when would you choose ValueTask?
Non-cached Task instances allocate a reference object on the heap, whereas Task.CompletedTask is reused for synchronous non-generic tasks. ValueTask is a discriminated union value type that allocates zero heap memory when the operation completes synchronously (such as from a cache or buffer). Choose ValueTask only on hot paths where synchronous completion occurs the vast majority of the time, and remember that a ValueTask can only be awaited once.
Follow-up tree
Answer, then open the next level. That is the order an interviewer digs in.
Reporteda candidate was asked Intervieweran interviewer asks it
What does ConfigureAwait(false) do, and do we need it in ASP.NET Core?
In UI apps or legacy ASP.NET with a SynchronizationContext, ConfigureAwait(false) tells the runtime not to marshal the continuation back onto the original context. In ASP.NET Core, there is no SynchronizationContext, so continuations always resume on an arbitrary thread pool thread. Thus, ConfigureAwait(false) is not required in application code, though it remains best practice in shared libraries.
What is the difference between Task and ValueTask, and when would you choose ValueTask?
Non-cached Task instances allocate a reference object on the heap, whereas Task.CompletedTask is reused for synchronous non-generic tasks. ValueTask is a discriminated union value type that allocates zero heap memory when the operation completes synchronously (such as from a cache or buffer). Choose ValueTask only on hot paths where synchronous completion occurs the vast majority of the time, and remember that a ValueTask can only be awaited once.
How does exception handling differ between await, .Result, and Task.Wait()?
await unwraps the exception and throws the original exception directly (e.g., InvalidOperationException), preserving the original stack trace. In contrast, .Result and Task.Wait() wrap all exceptions in an AggregateException. Note that when awaiting a composite task like Task.WhenAll, await unrolls only the first exception, whereas inspecting task.Exception reveals all faulted exceptions.
What happens if a client disconnects mid-request, and how should your async code handle it?
When a client disconnects, Kestrel cancels HttpContext.RequestAborted. By passing CancellationToken down to EF Core, HttpClient, and external service calls, the runtime aborts running database queries and socket reads immediately, throwing OperationCanceledException and freeing server resources instead of doing wasted work.
Production bugs, and how to tell them
Each one: what you see, why, the fix in code, and the line to say.
Thread-pool starvation via sync-over-async (.Result)
Server response p95 jumped to 14 seconds across all endpoints; CPU sat at 30%.
Cause
Calling .Result or .Wait() on an incomplete Task blocks the calling ThreadPool thread for seconds. Under burst traffic, all threads are blocked waiting, causing thread-pool starvation as the pool throttles thread injection to 1-2 per second.
Fix
Replace .Result with , make the method signature async Task, and pass throughout.
Lending.sln · where these 2 files sit
src/Services/
Say it
"A helper called an external API using .Result, blocking ThreadPool threads for seconds. At 40 concurrent requests the pool starved. We made it async end to end with await and enabled Roslyn analyzer VSTHRD002 to ban .Result in ."
Process crash from unhandled exception in async void
Application suddenly crashed and restarted; event log showed unhandled exception.
Cause
An asynchronous background notification method was declared as instead of async Task. When the email service threw a network exception, the caller had no Task to await, so the exception bypassed the try/catch block and crashed the process via the unhandled exception handler.
Fix
Change method return type from void to so the caller can it and catch exceptions cleanly.
Lending.sln · where these 2 files sit
src/Notifications/
Say it
"An async void method threw an exception that escaped try/catch and brought down the API. In ASP.NET Core, async void is banned because unhandled exceptions terminate the process; methods must always return Task or Task<T>."
ObjectDisposedException from returning Task inside using
System.ObjectDisposedException: Cannot access a disposed context.
Cause
A developer elided to optimize away the state machine while querying inside a using block. The method returned the pending Task and immediately disposed the DbContext before the database query finished executing.
Fix
Add inside the method so the resource remains alive until the asynchronous operation finishes.
Lending.sln · where these 2 files sit
src/Data/
Say it
"A method returned a Task directly inside a using block without awaiting it. The using block disposed the DbContext while the query was still in flight, throwing ObjectDisposedException. When disposing resources, you must await before exiting the block."
Production incident: Morning rollout stalled the dealer API
P1 · outage or data leakUDC Financeonly under loadDealer loan origination API
Composed, not a real event at UDC Finance. Every mechanism is real; the stakes and diagnostic signals are modelled on production thread-pool starvation incidents: Microsoft Learn: Diagnosing .NET ThreadPool Starvation
The story in 5 lines
At the New Zealand morning peak during UDC's wider rollout, every endpoint across the dealer portal API slowed to 14 seconds, including the health check.
CPU was low at 30% and SQL was fast, but showed ThreadPool thread counts climbing and thread queue length spiking into the hundreds.
A shared document helper called an external FIS document API using synchronous .Result inside loan disclosure requests.
Forty concurrent disclosure calls held forty threads blocked on I/O, starving the ThreadPool, which injects only one to two new threads per second.
We mitigated by toggling off on-demand PDF generation, then shipped an end-to-end async fix with background queuing and an analyzer banning .Result.
Who's who (4 people)
You
.NET developer on vendor team (L3). Owned the disclosure flow and async refactoring.
Sushmita
Backend lead in Navi Mumbai. Ran the bridge call and reviewed the emergency PR.
Nitin
Platform engineer. Scaled the App Service and monitored thread metrics.
Arman
UDC IT operations manager in Auckland. Chaired the P1 incident bridge.
Tap a step. The map lights the place where it happens.
Dealer portalNZ dealers online
Kestrel / APIApp Service .NET 8
ThreadPoolworker threads
FIS Doc APIexternal PDF service
Azure SQLloan database
Storage Queuebackground jobs
Doc WorkerBackgroundService
App InsightsKQL, metrics, CPU
You + SushmitaL3 vendor team
Azure DevOpsemergency CAB, slots
1/8 Detect
Dealers report frozen screens. Latency jumps to 14 seconds across all endpoints.
At 10:30 Auckland time, New Zealand vehicle dealers log in to submit applications. Within five minutes, portal requests crawl from 700 ms to 14 seconds.
Even lightweight health-check calls (/healthz) time out. Azure autoscale kicks in and adds two instances, but latency remains above 12 seconds.
An automated p95 alert (the latency below which 95% of requests finish) pages you at 04:05 IST. You immediately join the emergency opened by Arman and page backend lead Sushmita.
App Insights Alert: High p95 Server Latency
SEV-1 ALERT: Server Response Time > 5.0s
Resource: app-udc-dealers-prod
Metric: requests/duration (p95) = 14.2s
Health check: /healthz 503 Unhealthy
Active instances: 4 (autoscale maxed)
In theory
Autoscaling handles sudden bursts by spinning up additional instances to share the request load.
In practice
Autoscale added instances, but each new instance was crippled by the exact same sync-over-async blocking as soon as requests hit it.
Try it yourself · 15 minutes, on your Mac
dotnet new webapi -n ThreadStarveLab and set in Program.cs to simulate a constrained pool.
Add an endpoint app.MapGet("/sync-blocking", () => Task.Delay(3000).Result) that blocks with .Result.
Add a fast endpoint app.MapGet("/fast", () => "ok").
Fire 10 concurrent requests to /sync-blocking using curl or Bombardier, then hit /fast: notice /fast hangs for seconds waiting for a thread.
Replace .Result with await Task.Delay(3000) and repeat: /fast responds instantly in 2 ms even under 100 concurrent requests.
You should see
Sync-over-async locks up the entire web server; pure async keeps every non-blocked endpoint answering in milliseconds.
The senior move
"When an entire web API stalls while CPU is sitting low, I immediately check ThreadPool queue length and thread counts for thread starvation, and I enforce async all the way down with Roslyn analyzers."
SourcesSourced: Microsoft guidance on ThreadPool starvation diagnostics, .NET runtime ThreadPool injection rate limits (1-2 threads/sec), Roslyn VSTHRD002 analyzer rules, and mechanics. Composed: UDC Finance dealer portal context, names, dates, and query parameters.
Traps
Don't sayAsync and await make my database queries run faster.SayAsync does not reduce query execution time; it releases the server thread while waiting, allowing the web API to handle many more concurrent users.
Don't sayWrap synchronous database queries in Task.Run inside an ASP.NET Core controller.SayTask.Run borrows a thread-pool thread to block on another thread, burning extra context switches; real async I/O uses non-blocking asynchronous drivers with zero threads.
Don't sayAlways add ConfigureAwait(false) in ASP.NET Core controllers.SayASP.NET Core has no SynchronizationContext, so ConfigureAwait(false) is redundant in controller actions; it is only necessary in reusable class libraries.
Don't sayAlways replace Task with ValueTask for performance.SayValueTask is a struct optimization for methods that complete synchronously most of the time; on asynchronous paths it has slightly more overhead, and awaiting it multiple times causes bugs.
Traps
What should you say instead? Tap to check.
Don't sayAsync and await make my database queries run faster.
Say instead
Async does not reduce query execution time; it releases the server thread while waiting, allowing the web API to handle many more concurrent users.
Don't sayWrap synchronous database queries in Task.Run inside an ASP.NET Core controller.
Say instead
Task.Run borrows a thread-pool thread to block on another thread, burning extra context switches; real async I/O uses non-blocking asynchronous drivers with zero threads.
Don't sayAlways add ConfigureAwait(false) in ASP.NET Core controllers.
Say instead
ASP.NET Core has no SynchronizationContext, so ConfigureAwait(false) is redundant in controller actions; it is only necessary in reusable class libraries.
Don't sayAlways replace Task with ValueTask for performance.
Say instead
ValueTask is a struct optimization for methods that complete synchronously most of the time; on asynchronous paths it has slightly more overhead, and awaiting it multiple times causes bugs.
Live coding
Easy first, then harder. Set a timer and type it before you look.
Task 1
Reportedmedium · 15 minutes
Refactor a blocking controller and service chain to be completely asynchronous end to end, removing all .Result and .Wait() calls and supporting cancellation.
The candidate is given this blocking sync-over-async code to refactor.
Lending.sln · where this file sits
src/Api/Controllers/
Stuck? Hints
Change the controller action return type to Task<ActionResult<RateDto>> and add the async keyword.
Replace .Result with await.
Add a CancellationToken parameter to the action method and forward it to the service and database calls.
Show a solution
Lending.sln · where these 2 files sit
src/
Api/Controllers/
Application/
What the interviewer checks
Replaces .Result with await across the entire call chain
Returns Task<ActionResult<T>> instead of IActionResult
Accepts and passes CancellationToken down to HttpClient or EF Core
With AI tools
GitHub Copilot and Claude can quickly convert synchronous methods to async boilerplate and identify synchronous .Result calls across a codebase. However, AI frequently introduces subtle async anti-patterns: omitting , wrapping asynchronous I/O methods unnecessarily inside , or prematurely applying without measuring heap allocations. A senior engineer uses static analysis rules like in and verifies async state machine performance using and runtime counters.