Building Async APIs in ASP.NET Core the Right Way
Learn to build fast, safe async APIs in ASP.NET Core: async/await, CancellationToken, avoiding .Result deadlocks, and thread pool tips.
Building Async APIs in ASP.NET Core the Right Way
Imagine a small tea stall outside a busy railway station. One person, Ramesh, runs the whole stall. A customer asks for chai. Ramesh puts the milk on the stove to boil. Now he has a choice.
He can stand there and stare at the milk until it boils. Or he can take the next customer's order, give someone their change, and wipe the counter while the milk boils on its own. The moment the milk is ready, he comes back and finishes the chai.
The second Ramesh serves far more people with the same two hands. That is exactly what async programming does for your web API. The "waiting" parts (like reading a database or calling another service) happen on their own, and your server is free to help other requests in the meantime.
This guide shows you how to build async APIs in ASP.NET Core the right way so your server stays fast, calm, and able to serve a crowd.
What "async" really means
When your API talks to a database, a file, or another website, most of the time is just waiting. The CPU is doing nothing. It is like waiting for milk to boil.
A thread is a worker. ASP.NET Core has a limited pool of these workers (the thread pool). If a worker stands and waits during every database call, you run out of workers very quickly. New requests then have to stand in a queue.
Async lets a worker let go of the request while it waits. The worker goes off and helps someone else. When the database replies, any free worker picks the request back up and continues.
The big idea: async does not make one request faster. It lets your server handle many requests at the same time without running out of workers.
A simple async endpoint
Here is a tiny Minimal API endpoint that reads an order from a database. Notice three things: the method returns a Task, it uses await, and it accepts a CancellationToken.
app.MapGet("/orders/{id}", async (int id, AppDbContext db, CancellationToken ct) =>
{
Order? order = await db.Orders
.FirstOrDefaultAsync(o => o.Id == id, ct);
return order is null
? Results.NotFound()
: Results.Ok(order);
});Read it slowly:
asyncmarks the method so it can useawait.await db.Orders...says "start the database call, and let go of the thread until the answer comes back."ct(theCancellationToken) lets the work stop early if the user closes the browser. We will return to this.
The same idea works in a controller too.
[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
private readonly AppDbContext _db;
public OrdersController(AppDbContext db) => _db = db;
[HttpGet("{id}")]
public async Task<IActionResult> GetOrder(int id, CancellationToken ct)
{
var order = await _db.Orders.FirstOrDefaultAsync(o => o.Id == id, ct);
return order is null ? NotFound() : Ok(order);
}
}The number one rule: never block on async code
This is the most important lesson in the whole article. Do not call .Result, .Wait(), or .GetAwaiter().GetResult() on a task.
These all mean the same thing: "stop this worker and make it stand still until the task finishes." That is exactly the bad Ramesh who stares at the milk.
// BAD: blocks a thread pool worker the whole time
public IActionResult GetOrderBad(int id)
{
var order = _db.Orders.FirstOrDefault(o => o.Id == id); // sync version
var report = BuildReportAsync(order).Result; // .Result blocks!
return Ok(report);
}
// GOOD: frees the worker while waiting
public async Task<IActionResult> GetOrderGood(int id, CancellationToken ct)
{
var order = await _db.Orders.FirstOrDefaultAsync(o => o.Id == id, ct);
var report = await BuildReportAsync(order, ct);
return Ok(report);
}In old ASP.NET, .Result could cause a true deadlock that froze the request forever. ASP.NET Core removed the synchronization context, so that classic deadlock is mostly gone. But the new problem is arguably worse: you burn a thread pool worker for the entire wait. Under heavy traffic this causes thread pool starvation, your queue grows, and requests start timing out.
The rule is simple to remember: async all the way down. If something is async, await it. Do not block.
What happens when you block with .Result
Steps
Request
A new call arrives
Block thread
.Result holds a worker
Pool shrinks
Fewer free workers left
Queue grows
New requests wait in line
Timeouts
Server feels frozen
Async all the way down
"Async all the way down" means: if a method calls an async method, it should also be async, and so should its caller, right up to the controller or endpoint.
If you stop halfway and use .Result to "bridge" sync and async code, you bring back the blocking worker problem. Keep the chain unbroken.
Every arrow uses await. No worker stands still. That is a healthy async API.
CancellationToken: stop work nobody wants
Picture Ramesh starting to make a chai. Halfway through, the customer walks away to catch the train. Ramesh should stop, not waste the milk.
A CancellationToken is that signal. ASP.NET Core gives you one for free. If the client disconnects, the token is cancelled, and you can stop the database query, the HTTP call, or the loop early.
app.MapGet("/search", async (string q, AppDbContext db, CancellationToken ct) =>
{
var results = await db.Products
.Where(p => p.Name.Contains(q))
.Take(50)
.ToListAsync(ct); // ct passed in, so EF Core can cancel
return Results.Ok(results);
});Always pass the token to the async methods that accept it: EF Core, HttpClient, file streams, and so on. It saves your database and CPU from doing work that nobody is waiting for.
| Pattern | What it does | Good idea? |
|---|---|---|
await SomethingAsync(ct) | Frees thread, supports cancel | Yes |
.Result / .Wait() | Blocks a worker | No |
async void method | Exceptions can crash the app | No (except event handlers) |
Ignoring CancellationToken | Wastes work after disconnect | No |
Task.WhenAll(...) | Runs tasks together | Yes, when independent |
Run independent work at the same time
Sometimes one request needs two or three things that do not depend on each other. Maybe you need the user's profile and their recent orders. You can start both and wait for both together with Task.WhenAll.
app.MapGet("/dashboard/{userId}", async (int userId, AppDbContext db, CancellationToken ct) =>
{
Task<User?> userTask = db.Users.FindAsync(new object[] { userId }, ct).AsTask();
Task<List<Order>> ordersTask = db.Orders
.Where(o => o.UserId == userId)
.ToListAsync(ct);
await Task.WhenAll(userTask, ordersTask);
return Results.Ok(new
{
user = userTask.Result, // safe here: task already finished
orders = ordersTask.Result
});
});Here .Result is safe, because Task.WhenAll already guaranteed both tasks are done. The danger of .Result is only when the task has not finished yet.
One caution: a single EF Core DbContext cannot run two queries at the same time. If you parallelize EF queries, give each its own context (for example, with IDbContextFactory). For pure HTTP calls to other services, Task.WhenAll is perfect.
Parallel fetch with Task.WhenAll
Steps
Start A
Begin user fetch
Start B
Begin orders fetch
WhenAll
Wait for both
Combine
Build the result
Respond
Return JSON
Avoid async void
There is one shape of async you should almost never write: async void.
// BAD: if this throws, the app can crash and you can't catch it
public async void SaveLog(string message) { /* ... */ }
// GOOD: returns Task, so the caller can await and catch errors
public async Task SaveLogAsync(string message) { /* ... */ }With async void, the caller cannot await it and cannot catch its exceptions. An error inside can take down the whole process. The only normal place for async void is an event handler, which is rare in web APIs. Everywhere else, return Task.
ConfigureAwait(false): you usually do not need it
You may have read older articles that say "always add ConfigureAwait(false)." That advice was for the old ASP.NET, which had a synchronization context that could cause deadlocks.
ASP.NET Core does not have a synchronization context. So in your API controllers and endpoints, ConfigureAwait(false) does nothing useful. You can leave it out and keep your code clean.
There is one exception: if you write a shared library (a NuGet package) that might run inside a desktop app or old framework, then ConfigureAwait(false) in that library is still good manners. But for normal ASP.NET Core API code, skip it.
| Where your code runs | Use ConfigureAwait(false)? |
|---|---|
| ASP.NET Core controller or endpoint | No, it is redundant |
| ASP.NET Core service / repository | No, not needed |
| Shared library used by desktop/WinForms | Yes, it is polite and safe |
| Old (.NET Framework) ASP.NET | Yes, helps avoid deadlocks |
ValueTask for hot, often-synchronous paths
Most of the time, return Task or Task<T>. It is simple and works everywhere.
For a very hot path where the answer is often already in memory (like a cache hit), you can return ValueTask<T>. It avoids a small allocation when the result is ready right away.
public ValueTask<Product?> GetProductAsync(int id, CancellationToken ct)
{
if (_cache.TryGetValue(id, out var product))
return new ValueTask<Product?>(product); // no async overhead
return new ValueTask<Product?>(LoadFromDbAsync(id, ct));
}Be careful: a ValueTask must only be awaited once, and you should not store it. When in doubt, use plain Task. ValueTask is a tuning tool, not a default.
A picture of a healthy request
Here is the life of one request through a well-built async API. Each step that waits gives the thread back to the pool.
When the request is in AwaitingDb, the worker is free. That single fact is what lets a small server handle thousands of users at once.
Common mistakes and easy fixes
Let me list the traps I see most often, so you can spot them in code review.
- Blocking with
.Resultor.Wait(). Fix:awaitinstead, and make the methodasync Task. - Forgetting the
CancellationToken. Fix: acceptctin the signature and pass it to every async call. async voidmethods. Fix: returnTaskso errors can be caught.- Mixing sync and async EF Core calls. Fix: use the
...Asyncmethods everywhere (ToListAsync,FirstOrDefaultAsync,SaveChangesAsync). - Wrapping fast CPU work in
Task.Runinside a request. Fix: just call it directly;Task.Runon the server only moves work between pool threads, it does not free anything. - Parallel queries on one
DbContext. Fix: one context per concurrent query, or run them one after another.
A complete, clean example
Putting it together, here is a small service and endpoint that follow every rule above.
public class OrderService
{
private readonly AppDbContext _db;
private readonly HttpClient _http;
public OrderService(AppDbContext db, HttpClient http)
{
_db = db;
_http = http;
}
public async Task<OrderView?> GetOrderViewAsync(int id, CancellationToken ct)
{
var order = await _db.Orders
.FirstOrDefaultAsync(o => o.Id == id, ct);
if (order is null)
return null;
// call another service for live shipping status
var status = await _http.GetFromJsonAsync<ShippingStatus>(
$"shipping/{order.TrackingId}", ct);
return new OrderView(order, status);
}
}
// Endpoint
app.MapGet("/orders/{id}/view", async (
int id, OrderService service, CancellationToken ct) =>
{
var view = await service.GetOrderViewAsync(id, ct);
return view is null ? Results.NotFound() : Results.Ok(view);
});Every wait uses await. The token flows from the endpoint down to EF Core and HttpClient. No blocking, no async void, no leftover ConfigureAwait. This is async done the right way.
A quick note on libraries
You may have used MediatR or MassTransit to organize async work. Both moved to a commercial license in their newer versions. They are still fine tools, but for a small project you can write clean async services by hand, as shown above, without any extra dependency. Choose based on your team and budget, not hype.
Quick recap
- Async lets your server handle many requests at once by freeing threads during waits, like a tea seller who works while the milk boils.
- Never block on async code with
.Result,.Wait(), or.GetAwaiter().GetResult(). It starves the thread pool under load. - Go async all the way down: if a method is async, await it, and make its caller async too.
- Always accept and pass a
CancellationTokenso work stops when the client leaves. - Use
Task.WhenAllfor independent work, but give each EF Core query its ownDbContext. - Avoid
async voideverywhere except event handlers; returnTaskso errors can be caught. - In ASP.NET Core you do not need
ConfigureAwait(false); it only matters in shared libraries. - Reach for
ValueTaskonly on hot, often-synchronous paths, and await it just once.
References and further reading
- Common async/await bugs (Microsoft Learn)
- Asynchronous programming scenarios (Microsoft Learn)
- CA2007: Do not directly await a Task (Microsoft Learn)
- ConfigureAwait FAQ (.NET Blog)
- Don't Block on Async Code (Stephen Cleary)
Related Posts
How to Scale Long-Running API Requests in .NET: A Beginner's Guide
Learn how to handle slow, long-running API requests in .NET using the 202 Accepted pattern, background services, channels, and status polling.
How to Build a URL Shortener With .NET: A Beginner's Step-by-Step Guide
A friendly, step-by-step guide to building a URL shortener in .NET 10 using minimal APIs and EF Core. Learn short codes, redirects, and storage.
Master Claims Transformation for Flexible ASP.NET Core Authorization
Learn claims transformation in ASP.NET Core to enrich the user identity and build flexible, policy-based authorization with IClaimsTransformation.
Using Scoped Services From Singletons in ASP.NET Core
Learn the safe way to use scoped services inside a singleton in ASP.NET Core using IServiceScopeFactory, with simple examples and clear diagrams.
Getting Started With Dapr for Building Cloud-Native Microservices in .NET
A beginner-friendly guide to Dapr for .NET developers: learn sidecars, state, pub/sub, and service invocation to build cloud-native microservices.
Introduction to Dapr for .NET Developers: A Beginner Guide
A warm, beginner-friendly introduction to Dapr for .NET developers, covering sidecars, building blocks, state, pub/sub, and service invocation in plain C#.