How does async/await work, and why use it in a web API?
Asked at Deloitte, Infosys, TCS, IBM, Neudesic and Wipro tech rounds, and on AmbitionBox by many product firms.
Say this first · about 30 seconds
async/await lets a method wait for slow input and output, like a database call, without holding a while it waits.
async marks the method. It allows inside, and the method returns a : a result that comes later.
await: if the result isn't ready, the method pauses and its thread goes back to the thread pool, the app's shared set of threads, to serve other requests. When the result arrives, a pool thread carries on from the same line.
Inside, the compiler turns the method into a small state machine: an object that remembers which it reached and the values of its local variables.
Why in a web API: the same few threads serve many more requests at once, because no thread sits idle waiting on SQL or an HTTP call.
My rules: async all the way down, never .Result or .Wait(), and pass the through.
Make it yours · BMW FS lease buyout quotes (invented example)
Situation: at BMW FS, the dealer portal shows what it costs to buy a leased car at the end. Our API asks FIS for the ; at month-end that takes 20 seconds.
What I found: every method was async, but never passed ct to the FIS client. So each refresh left the old FIS call running for nobody.
What I did: passed ct down to the HTTP call. Now, when the dealer's browser disconnects, ASP.NET Core cancels the token, our call is cancelled (FIS may still finish its work), and an (usually ) travels up through each and ends the request.
What I'd say: async frees the thread while we wait; the token stops work nobody waits for. Pass it through every layer.
The code, simplified
Invented to show the shape, in C#. Tap Next to follow one quote, and the token, down to FIS and back.
BMW.sln · where these 3 files sit
src/
Api/Controllers/
Application/Quotes/
Infrastructure/Fis/
Diagram · step 1A dealer opens a buyout quote. ASP.NET Core gives the action a ct tied to this request.
Controllergets ct
BuyoutServicepasses ct
FisQuoteClientpasses ct
FISslow at month-end
ct = this request's
QuotesController.cs · line 17 · 1 of 3Receives the request's token. ASP.NET Core fills it in and cancels it if the dealer's browser disconnects.
Fix later in your repoExample written for this card: invented, not from a real codebase.
Say it out loud, then check
Show the 30-second answer
async/await lets a method wait for slow input and output, like a database call, without holding a while it waits.
async marks the method. It allows inside, and the method returns a : a result that comes later.
await: if the result isn't ready, the method pauses and its thread goes back to the thread pool, the app's shared set of threads, to serve other requests. When the result arrives, a pool thread carries on from the same line.
Inside, the compiler turns the method into a small state machine: an object that remembers which it reached and the values of its local variables.
Why in a web API: the same few threads serve many more requests at once, because no thread sits idle waiting on SQL or an HTTP call.
My rules: async all the way down, never .Result or .Wait(), and pass the through.
Check your answer: tick what you said
You said 0 of 9
Then add your example: BMW FS lease buyout quotes (invented example)
Situation: at BMW FS, the dealer portal shows what it costs to buy a leased car at the end. Our API asks FIS for the ; at month-end that takes 20 seconds.
What I found: every method was async, but never passed ct to the FIS client. So each refresh left the old FIS call running for nobody.
What I did: passed ct down to the HTTP call. Now, when the dealer's browser disconnects, ASP.NET Core cancels the token, our call is cancelled (FIS may still finish its work), and an (usually ) travels up through each and ends the request.
What I'd say: async frees the thread while we wait; the token stops work nobody waits for. Pass it through every layer.
See it
Thread pool2 threads in this demo
T1free
T2free
1/13ASP.NET Core keeps a pool of to run requests. Here, just two: T1 and T2.Before: code without . T1 runs A and sends its query, then just sits waiting for the answer.B takes T2 and waits too. C arrives: no free thread, so it queues, though both threads do nothing.Fix: the database call. Same two threads, same three requests. Watch the threads now.Request A arrives. Free thread T1 runs its code: the controller, then the service.The code reaches on the database call. The query goes off to the database.The answer isn't back yet, so A's method pauses. Its place is saved, and T1 goes back to the pool.T1 is free, so it takes Request B. No thread is waiting on A's query.Request C arrives and gets T2. Two threads, three requests in progress.The database answers A. A's method is ready to carry on, on any free thread.C is done, so T2 is free. T2 picks up A on the line after . A thread other than T1.A's code sends the loan back as JSON. T2 goes back to the pool.Waiting held no thread. That is how a few threads serve many requests at once.
In plain words
Think of a waiter in a restaurant. He takes your order to the kitchen, then serves other tables while your food cooks. He does not stand at the kitchen door. The waiter is a ; the kitchen is the database.
The order slip is a : proof that your result is coming.
is the waiter walking away from the kitchen door. When the food is ready, any free waiter brings it out. Your method also carries on, on whichever thread is free.
.Result is the waiter standing at the kitchen door until the food is ready. On a quiet day nobody notices. On a busy night every waiter is standing there, and new guests wait outside. That is thread-pool starvation.
The senior answer · about 2 minutes
What it does
async and await let a method wait for I/O, like a SQL query or a call to another API, without keeping a thread busy while it waits.
When the code reaches and the Task isn't finished, the method saves where it is and returns. The thread goes back to the thread pool and serves someone else's request.
When the database answers, the rest of the method is put on the thread pool's queue of work, and whichever pool thread is free picks it up from the line after the .
The compiler does the bookkeeping. It turns the method into a state machine: the local variables become fields, and a state number says which to resume after.
Why it matters in a web API
ASP.NET Core serves every request from one shared thread pool. If each request holds its thread for a 300-millisecond SQL call, threads run out long before the CPU does.
When that happens, the pool adds new threads only slowly. After the first few, it adds about one or two a second, so a traffic spike becomes a queue and then timeouts. That is thread-pool starvation. You recognise it because requests are slow while the CPU is almost idle.
With await, the same threads serve far more requests at once. One request is not faster; the server just handles more of them, so we need fewer instances.
The rules I follow
Async all the way. Once one call is async, every caller up to the controller returns a Task and awaits it. I never block on a Task with .Result,.Wait() or .GetAwaiter().GetResult() in request code.
I pass the from the action down to every EF Core and call. If the customer closes the page, the query stops instead of running for nothing.
No , except UI event handlers. An method returns no Task, so its exception has no way back to the caller. Nobody can catch it, and it crashes the whole process.
Independent calls can run together with , but never two queries on the same , EF Core's object for one request's database work, because it can only run one query at a time.
In production
I find blocking calls by measuring, not guessing. dotnet-counters, Microsoft's command-line tool for live .NET numbers, shows the thread pool's thread count climbing slowly and its queue of waiting work growing. shows requests slow while the SQL and HTTP calls inside them stay fast.
In review I search for .Result and .Wait(), and we turn on the analyzer rule VSTHRD002, which flags blocking waits, so the build fails on the next one.
One request through every idea
, the Task, , the state machine, the thread going back to the pool, another thread carrying on, and the , in the order they happen for one GET /api/loans/{id}.
Controller
LoanServicestate machine
Thread pool
EF Core + SQL
GetAsync(id, ct) on T1
1/8 changes nothing yet: runs on T1 like any method, line by line.EF Core sends the SQL and hands back a at once. It isn't finished. checks the Task. Not done, so the state machine saves its place: state 1, plus id and ct.The method returns an unfinished Task. The controller awaits it the same way, and T1 goes back.The query is running. The rides with it: if the client leaves, it stops.SQL answers. The Task completes, and the rest of is queued on the thread pool.Free thread T2 resumes it at state 1, the line after , with id and ct restored. finishes and its Task completes. The controller resumes the same way and replies.
The story
Before
Before async, a web API called the database the plain way: db.Loans.FirstOrDefault(...), the non-async twin of . The running the request sent the query, then sat and waited for the answer, doing nothing.
Request A
Thread pool2 threads
T1waiting on SQL
T2free
Databaseanswers in 300 ms
1/8Before async: Request A runs on T1, and T1 waits while the database works.Request B takes T2 and waits too. Both threads are doing nothing but waiting.Request C arrives. No free thread, so C waits in a queue, while the CPU sits idle.On the 1st of the month, hundreds arrive. The pool adds threads slowly, so the queue keeps growing.Requests time out. More threads is not the fix. Not holding a thread while waiting is.With , T1 sends the query and goes straight back to the pool.Free threads drain the queue. When an answer comes back, any free thread finishes that request.Same two threads, no queue. The waiting happens in the database, not on a thread.
Why it had to change
Under load every thread was stuck waiting on I/O (input and output: the database, another API, a file). New requests queued, and the server timed out with the CPU almost idle. That is thread-pool starvation.
How it works now
Put on the slow call. If the answer isn't there yet, the method pauses and hands its thread back. When the answer arrives, a pool thread carries on from the same line.
What happens next
The compiler writes the hard part for you: it turns the method into a small state machine that remembers which it reached and the values of its local variables. EF Core and give you methods ending in Async that return a .
What we gained
Far more requests per server with the same threads, fewer instances to pay for, and no timeouts at month-end peaks. One request is not faster; the server handles more of them at once.
In the lending API
The controller that serves GET /api/loans/{id}. The three marked lines are what makes it async.
Only the key lines are shown; Deep walks the whole file.
Lending.sln · where this file sits
src/Api/Controllers/
⋯ 1 line in Deep⋯ 2 lines in Deep
LoansController.cs · line 9 · 1 of 3 lets this method use . It returns a , so ASP.NET Core knows the response comes later and can free the thread meanwhile.
The code, file by file
One GET request in the lending API, file by file: controller, service, repository. Every layer returns a Task and awaits the next one. Press Next line to follow Request A through the diagram.
Lending.sln · where these 3 files sit
src/
Api/Controllers/
Application/Servicing/Loans/
Infrastructure/Servicing/
Diagram · step 1ASP.NET Core keeps a pool of to run requests. Here, just two: T1 and T2.
Thread pool2 threads in this demo
T1free
T2free
LoansController.cs · line 6 · 1 of 7Who builds this: ASP.NET Core, once per request. The passes in (registered as in Program.cs).
What they ask next
The follow-ups interviews really ask. Answer out loud, then tap to check.
ReportedDoes async make a request faster?
No. One request takes about as long, a tiny bit longer because of the state machine. The win is scalability: while it waits, its thread serves other requests, so one server handles many more requests at once.
ReportedWalk me through what happens when the code hits await.
The method runs normally until the first . EF Core hands back an unfinished Task. The compiler-built state machine saves its state number and local variables, and the method returns that unfinished Task to its caller, which does the same. The thread goes back to the pool. When the query finishes, the rest of the method goes into the thread pool's queue of work, and a free thread resumes it at the saved state. The storyboard in Deep shows that whole flow in one request.
They block the calling thread until the Task finishes, which undoes the whole point of async. In ASP.NET Core, under load, every pool thread ends up blocked and new requests queue: thread-pool starvation. In apps that have a , like old ASP.NET or a desktop app, it can also deadlock.
Answer, then open the next level. That is the order an interviewer digs in.
Reporteda candidate was asked Intervieweran interviewer asks it
ReportedDoes async make a request faster?
No. One request takes about as long, a tiny bit longer because of the state machine. The win is scalability: while it waits, its thread serves other requests, so one server handles many more requests at once.
For CPU-bound work, like building a 60-month repayment schedule in memory. There is nothing to wait for, so there is nothing to free. Wrapping it in in a web API only moves the work to another pool thread and adds overhead. Async is for I/O: databases, HTTP calls, files, queues.
ReportedWalk me through what happens when the code hits await.
The method runs normally until the first . EF Core hands back an unfinished Task. The compiler-built state machine saves its state number and local variables, and the method returns that unfinished Task to its caller, which does the same. The thread goes back to the pool. When the query finishes, the rest of the method goes into the thread pool's queue of work, and a free thread resumes it at the saved state. The storyboard in Deep shows that whole flow in one request.
Roughly this, simplified from what the compiler writes for . The locals became fields, so they survive the pause. runs first on T1. If the Task isn't finished, it sets state 1 and returns, freeing T1. When the Task completes, the pool calls again, on T2, and this time it skips straight to the part after the . You never write this; you only need to know it exists.
// compiler-written, simplified// state, awaiter, loan: fields now;// so are id, ct and repovoid MoveNext()
{
if (state == 0)
{
awaiter = repo.GetAsync(id, ct)
.GetAwaiter();
if (!awaiter.IsCompleted)
{
state = 1;
// when done, call MoveNext again
awaiter.OnCompleted(MoveNext);
return; // T1 freed
}
}
// state 1: called again, on T2
loan = awaiter.GetResult();
SetResult(LoanResponse.From(loan));
}
Does the method continue on the same thread?
In ASP.NET Core, usually not. There is no , so any free pool thread runs the rest. That is fine: your local variables come back with it, and is still the same request. Don't store anything per-thread with ; flows with the async code instead.
ReportedWhy is .Result or .Wait() dangerous?
They block the calling thread until the Task finishes, which undoes the whole point of async. In ASP.NET Core, under load, every pool thread ends up blocked and new requests queue: thread-pool starvation. In apps that have a , like old ASP.NET or a desktop app, it can also deadlock.
ReportedWhat does ConfigureAwait(false) do, and when does .Result deadlock?
Short version: in old ASP.NET and desktop apps, the code after an must come back to the one thread that .Result is blocking, so both wait forever. ConfigureAwait(false) says "any thread will do", which matters in shared libraries. ASP.NET Core has no , so there it starves instead of deadlocking. The full answer is its own card: .
The interface I implement is synchronous. What then?
Change the interface to return a Task, and fix the callers. That is the real fix. If it is a framework interface you can't change, look for its async version (most have one, like ). Blocking with .GetAwaiter().GetResult() is still blocking; it only gives a cleaner exception.
Where does the CancellationToken come from in a controller, and what happens if you don't pass it?
Add a parameter to the action and ASP.NET Core fills it with , which fires when the client disconnects. Pass it to every EF Core and call. If you don't, a customer who closes the page leaves a 20-second report query running on SQL for nothing. When it fires, the call throws ; keep that out of your error alerts, since the client is already gone.
What is the difference between a Task and a Thread?
A thread is a that runs code. A Task is a promise of a result. For I/O, a Task uses no thread while it waits. See and the full card .
How do you call two services at the same time?
Start both calls, then await Task.WhenAll(...), so the total time is the slower call, not the sum. Never do this with two queries on the same : it handles one operation at a time and throws (see the parallel bug on the ).
How would you find sync-over-async in production? How did you measure it?
Three signals together: latency up while CPU stays low; showing the thread pool thread count climbing one or two a second with a long queue; and showing requests slow while their SQL and HTTP dependencies are fast. Then dotnet-stack on one instance shows many threads stuck in . To stop the next one, the analyzer rule VSTHRD002 ("avoid synchronous waits") fails the build.
Is async void ever OK?
Only for event handlers in desktop apps, where the signature must be void. In a web API, never: its exception can't be caught by the caller and crashes the process.
Production bugs, and how to tell them
Each one: what you see, why, the fix in code, and the line to say.
Sync over async: .Result starves the thread pool
No exception. Under load: requests take 30 s, the gateway returns 504, CPU sits at 15%, and the credit bureau answers in 800 ms.$ dotnet-counters monitor -n Lending.Api
ThreadPool Queue Length 412
ThreadPool Thread Count 58 (climbing ~1/s)
Cause
was synchronous, so it called the async client and blocked on it with .Result. Each request held its thread for the whole call.
Fix
Make the method async and the call, then make every caller up to the controller async too. Pass the down.
Before · CreditCheckService.cs · line 2 · 1 of 4Built by the container. is a typed : a class that wraps an for one API, registered with .
Say it
We had a synchronous service calling an async HTTP client with .Result. Each request held a pool thread for the whole call, so under load the pool ran dry and the queue grew while the CPU sat idle. I made the chain async from the controller down and passed the token through, and we turned on the analyzer rule that flags blocking waits.
async void: one failed SMS crashes the whole API
Unhandled exception. System.Net.Http.HttpRequestException: Connection refused (sms-gateway:443)
at Lending.Api.Notifications.WelcomeSms.Send(Loan loan)
at System.Threading.Tasks.Task.<>c.<ThrowAsync>b__128_1(Object state)
at System.Threading.ThreadPoolWorkQueue.Dispatch()
(process exits; App Service restarts it; every in-flight request fails)
Cause
was , so it returned no Task. When it failed after its , .NET had nowhere to deliver the exception, so it threw it again on a thread-pool thread with nothing around it to catch it. An uncaught exception on any thread ends the process.
Fix
Return a and it inside a try and catch block, so a failed SMS is logged and the loan is still created. Better still, put the SMS on a queue for a background .
Before · WelcomeSms.cs · line 2 · 1 of 5Built by the container. wraps the SMS provider's HTTP API.
Say it
A welcome SMS method was . When the SMS gateway was down, its exception had no Task to go to, so it crashed the process and took every in-flight request with it. I changed it to return a Task and awaited it in a try/catch; later we moved it to a queue so the request doesn't wait on SMS at all.
Forgot await: "Cannot access a disposed context"
System.ObjectDisposedException: Cannot access a disposed context instance. A common cause of this error is disposing a context instance that was resolved from dependency injection and then later trying to use the same context instance elsewhere in your application.
Object name: 'ServicingDbContext'.
(build warning ignored: CS4014 Because this call is not awaited, execution of the current method continues before the call is completed)
Cause
The controller called audit.WriteAsync(...) without . The response went out, the request's scope disposed the , and then the audit code tried to save with it.
Fix
the call, so the request (and its ) stays alive until the audit row is saved. Treat warning CS4014 as an error.
Diagram · step 1A POST arrives. The request's scope holds the controller and its .
Request scopeone per request
CreateAsync
DbContext
Before · LoansController.cs · line 2 · 1 of 4Runs inside the request's , which holds this request's .loans and audit (an ) came in through the controller's constructor; the container gave the same request's .
Say it
Someone dropped an await on an audit write, so the request finished and its DbContext was disposed while the audit was still running. Some audit rows never saved. We awaited it and made the not-awaited warning a build error.
Production incident: Payoff quotes timed out at month-end
P2 · serious, has a workaroundBMW FSonly under loadLending API: payoff quotes
On 1 October, BMW FS customers and dealers asking for a got spinning screens and timeouts. Every other page of the portal was slow too.
showed 30-second requests but fast SQL and FIS calls, with CPU at 15%: the API was waiting for threads, not for work.
was synchronous and blocked on the FIS client with .Result. Each quote held a pool thread for over a second; the month-end spike used up the pool.
We scaled out to buy time, then shipped the chain async end to end with the passed through.
The analyzer rule for blocking waits now fails the build, and a load test runs before every release.
Who's who (5 people)
You
.NET developer on the vendor team (L3). Finds the cause, writes the fix and the test.
Tech lead
Vendor senior. Runs the bridge call, reviews every change.
L2 pod
Vendor support in Navi Mumbai. First look, runbooks, scaling.
BMW FS IT manager
Approves changes, including the fast-track ones.
BMW FS digital team
Owns the customer portal that calls our API.
Tap a step. The map lights the place where it happens.
Customer portalcustomers and dealers
Lending APIApp Service, 3 instances
FIS quote serviceabout 1.2 s per call
Azure SQLfast: 40 ms
Azure Monitor alertp95 over 5 s
App Insightsrequests vs dependencies
L2 podNavi Mumbai
You + tech leadL3: our code
Azure DevOpsbuild, load test, slots
1/8 Detect
Month-end traffic hits the API. The response-time alert fires and pages .
Wed 1 Oct, 09:10 ET (18:40 IST)Azure Monitor alert, then L2
The 1st of the month: customers check amounts before their autopay, and dealers request on lease returns. Quote traffic is about ten times a normal day.
The alert "p95 response time over 5 s" fires on the lending API and pages the . BMW FS's digital team calls minutes later: screens spin and time out.
Not only : every endpoint on the same API is slow, even ones that never touch FIS.
The alert (Azure Monitor)
Sev 2 · lending-api-prod
Rule: requests/duration p95 > 5 s for 5 min
Fired: 2025-10-01 13:10 UTC
Current p95: 31.4 s · CPU: 15%
In theory
A load test before the feature went live would have shown the thread count climbing at month-end traffic.
In practice
The feature shipped in August and passed , where testers send one request at a time. Blocking code only fails under load.
Try it yourself · 20 minutes, on your Mac
Clone Microsoft's diagnostic scenarios sample (linked from the Microsoft Learn starvation tutorial) and dotnet run it.
Send load to the taskwait endpoint with a load tool such as bombardier, and watch dotnet-counters monitor in a second terminal.
Do the same against taskasyncwait, the async version, and compare the thread count and the latency.
You should see
The blocking endpoint's thread count climbs slowly and latency jumps to seconds; the async endpoint stays near 500 ms with few threads.
The senior move
"When requests are slow but the CPU is idle and the database is fast, I look for threads that are waiting, not working. Then I make the chain async and make the build fail on the next blocking wait."
SourcesSourced: the starvation signals, the thread growth rate of one or two a second and the dotnet-counters names (Microsoft Learn starvation tutorial), sync-over-async guidance (David Fowler's async guidance), the analyzer rule VSTHRD002 (Microsoft.VisualStudio.Threading.Analyzers), and the scale-out command. Composed: BMW FS's systems, every date, number and name, and the (standard columns).
Traps
Don't sayasync runs my method on a new thread.Say creates no thread. While the method waits on I/O, no thread is used at all.
Don't sayawait blocks until the result comes back.Say pauses the method, not the thread. The thread goes back to the pool and serves other requests.
Don't sayAsync makes each request faster.SayIt lets the server handle more requests at once. A single request is about the same speed.
Don't sayI'll wrap it in Task.Run to make it async.SayIn a web API, just borrows another pool thread. Use the real async API, like .
Don't sayI'd spot blocking calls in code review.SayI'd measure: thread count and queue length, plus request time against dependency time.
Traps
What should you say instead? Tap to check.
Don't sayasync runs my method on a new thread.
Say instead
creates no thread. While the method waits on I/O, no thread is used at all.
Don't sayawait blocks until the result comes back.
Say instead
pauses the method, not the thread. The thread goes back to the pool and serves other requests.
Don't sayAsync makes each request faster.
Say instead
It lets the server handle more requests at once. A single request is about the same speed.
Don't sayI'll wrap it in Task.Run to make it async.
Say instead
In a web API, just borrows another pool thread. Use the real async API, like .
Don't sayI'd spot blocking calls in code review.
Say instead
I'd measure: thread count and queue length, plus request time against dependency time.
Live coding
Easy first, then harder. Set a timer and type it before you look.
Task 1
Question index: Musteasy · 5 minutes
This repository method blocks a thread while the database works. Make it async.
PayoffController.cs · line 2 · 1 of 1 is passed in by the container.
Stuck? Hints
Start at the bottom: which calls already return a Task?
Each method that awaits must itself return a Task. Work upwards to the controller.
Where does the controller get a , and who needs it?
The solution passes ct into repo.GetAsync and fis.GetPayoffAsync, so add a CancellationToken ct parameter to both methods and their interfaces, and pass it to EF Core and inside.
Show a solution
Lending.sln · where these 2 files sit
src/
Api/Controllers/
Application/Payoff/
Diagram · step 5Request A arrives. Free thread T1 runs its code: the controller, then the service.
Thread pool2 threads in this demo
T1free
T2free
PayoffController.cs · line 1 · 1 of 5Routing runs this on a free pool thread, T1 in the diagram.
What the interviewer checks
No .Result,.Wait() or .GetAwaiter().GetResult() left.
Service returns and is named ; the interface changes too.
Controller action is async Task<ActionResult<PayoffQuote>> and awaits the service.
taken in the action and passed to EF Core and the FIS client; repo.GetAsync and fis.GetPayoffAsync get a ct parameter too.
The repository and FIS calls stay one after another: the second needs the first's .
Senior extra: say how you'd prove it worked: a load test before and after, and the thread count in staying flat.
With AI tools
Interviewers now ask how you check AI-written code. A story that lands: Copilot "fixed" a compile error, where an async client didn't fit a synchronous interface, by adding .GetAwaiter().GetResult(). It compiled and passed the tests, because tests run one request at a time. I rejected it and made the interface async instead, because blocking calls only fail under load. Now I ask the AI to review its own diff for blocking waits, and the analyzer rule catches what it misses.