Skip to main content
SEMastery
ASP.NETintermediate

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.

12 min readUpdated October 28, 2025

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.

Blocking vs async: how a thread is used while waiting

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:

  • async marks the method so it can use await.
  • await db.Orders... says "start the database call, and let go of the thread until the answer comes back."
  • ct (the CancellationToken) 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

Request
Block thread
Pool shrinks
Queue grows
Timeouts

Steps

1

Request

A new call arrives

2

Block thread

.Result holds a worker

3

Pool shrinks

Fewer free workers left

4

Queue grows

New requests wait in line

5

Timeouts

Server feels frozen

Blocking one worker per request leads to a starved thread pool under load.

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.

Async flows up the whole call chain, end to end

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.

PatternWhat it doesGood idea?
await SomethingAsync(ct)Frees thread, supports cancelYes
.Result / .Wait()Blocks a workerNo
async void methodExceptions can crash the appNo (except event handlers)
Ignoring CancellationTokenWastes work after disconnectNo
Task.WhenAll(...)Runs tasks togetherYes, 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

Start A
Start B
WhenAll
Combine
Respond

Steps

1

Start A

Begin user fetch

2

Start B

Begin orders fetch

3

WhenAll

Wait for both

4

Combine

Build the result

5

Respond

Return JSON

Start independent calls together, then wait once for all of them.

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 runsUse ConfigureAwait(false)?
ASP.NET Core controller or endpointNo, it is redundant
ASP.NET Core service / repositoryNo, not needed
Shared library used by desktop/WinFormsYes, it is polite and safe
Old (.NET Framework) ASP.NETYes, 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.

The state of a single async request from start to finish

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 .Result or .Wait(). Fix: await instead, and make the method async Task.
  • Forgetting the CancellationToken. Fix: accept ct in the signature and pass it to every async call.
  • async void methods. Fix: return Task so errors can be caught.
  • Mixing sync and async EF Core calls. Fix: use the ...Async methods everywhere (ToListAsync, FirstOrDefaultAsync, SaveChangesAsync).
  • Wrapping fast CPU work in Task.Run inside a request. Fix: just call it directly; Task.Run on 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 CancellationToken so work stops when the client leaves.
  • Use Task.WhenAll for independent work, but give each EF Core query its own DbContext.
  • Avoid async void everywhere except event handlers; return Task so errors can be caught.
  • In ASP.NET Core you do not need ConfigureAwait(false); it only matters in shared libraries.
  • Reach for ValueTask only on hot, often-synchronous paths, and await it just once.

References and further reading

Related Posts