← Back to list

Async Pitfalls in ASP.NET Core That Break Production

Best Practices for Asynchronous Programming

Krish Panchal in CodeToDeploy · 2025-12-29 18:10 · 107 claps · 5.7 min read paywalled
#dotnet-core #asynchronous-programming #software-development #programming #best-practices
Open on Medium ↗
Wiki topics: 💻 · Programming

Async Pitfalls in ASP.NET Core That Break Production

Best Practices for Asynchronous Programming

Most ASP.NET Core outrages related to async are not caused by complex race conditions. They’re caused by small async mistakes that pass code review, work locally and fail under load. To target these pitfalls lets understand these 3 things:

🎓 Start Free — Unlock 6,500+ In-Demand Tech Courses

👉 If you are a non member — Access this story for free

Why async is everywhere in ASP.NET Core? Why async bugs are hard to detect? and Why these issues appear only in production? 🤔

So, the answer to our questions lies in the fact that any system should be designed to scale and async is the mechanism that makes it possible. Web Applications spend most of their time waiting for I/O, not doing CPU work so Async allows the framework to handle more requests without wasting threads.

Another reason is that async failure is hidden in timing and load not logical errors, everything works perfectly unless the system is in load and the very fact that all these things are exposed in production makes it pretty difficult to navigate it properly.

Sounds Scaryy right!! 😢. It is….

How Async actually works in ASP.NET Core

Every incoming ASP.NET Core request is executed on a thread borrowed from the .NET ThreadPool ( a collection of preinitialized worker threads controlled by the Common language Runtime). Now the threads in the threadpool are not created or destroyed instead they are maintained as it is and used as per demand making them a shared and finite resource.

So when a request blocks threads unnecessarily, it reduces the number of threads available for other process to function directly impacting performance under load.

One thing to take notice is that Blocking a thread ≠ blocking a request. Lets understand this, When a code performs blocking operation such as .Result, .Wait() or Thread.Sleep the thread remains occupied doing nothing. In contrast a properly awaited async operations allow the request to pause without tying up a thread, letting the ThreadPool serve other incoming requests.

Async programming in ASP.NET Core does not create additional threads. Instead, it releases the current thread back to the ThreadPool while waiting for I/O to complete so, when the operation finishes the request continues on any available thread. This is what enables ASP.NET Core applications to scale efficiently under high concurrency. This nature is very crucial as the threadpool is designed to execute many short lived tasks by re-using threads instead of creating and destroying them which is expensive.

Exploring the Dangers ⚠️

Now, that we have understood how the aync works, its time to see what are the common pitfalls that might cause problems in production

1. Blocking on async code (.Result, .Wait())

Suppose you are writing this code

var user = _service.GetUserAsync(id).Result;
// or
_service.GetUserAsync(id).Wait();

here, .Result() or .wait() block the current thread until the async operation is complete, now since the pool has limited threads this can cause a serious issue when the traffic on the app increases. A quick solution to this is to use await as the thread is freed until the response arrives, making execution faster.

var user = await _service.GetUserAsync(id);

2. The Void return type with async

Using Async with void in the return type outside event handlers can cause exceptions that even the try{} catch{} block cannot catch.

but wait isn’t it made for catching exceptions 🤔

Well, the exception is raised on the SynchronizationContext which is the threadpool that makes the application go crashing down! So instead we should focus in using Async with Task

public async Task ProcessOrderAsync() 
//avoid using void here instead use Task
{
    await _service.SaveAsync();
}

3. Fire and forget work inside controllers 🔥

Starting background work inside a controller and not awaiting it is a common mistake.

public IActionResult CreateOrder(Order order)
{
    Task.Run(() => _emailService.SendConfirmation(order));
    return Ok();
}

This is a bad practice as the task can be killed on app restart, consumes threadpool threads needed for requests, no guarantee that the work actually completes. The Correct approach is to move the background task out of the controller by using a BackgroundService / IHostedService or a message broker.


public async Task<IActionResult> Create(Order order)
    {
        await _queue.EnqueueAsync(order);
        return Accepted();
    }

A service created can stay alive for the entire duration of the application waiting asynchronously for the work and while waiting it doesn’t burn CPU or threads as well. The moment an order is enqueued, the background service recieves the order and the work continues to run while the request returns the response.

4. Ignoring Cancellation Token

Ignoring CancellationToken causes ASP.NET Core applications to keep doing work even after the client has gone away.

public async Task<IActionResult> Get()
{
    await _service.DoWorkAsync();
    return Ok();
}

Here, If the client disconnects or the request times out, the server continues executing this work. To rectify this, we must make correct use of the cancellation tokens. If a request can be canceled, your async code should support cancellation.

public async Task<IActionResult> Get(CancellationToken ct)
{
    await _service.DoWorkAsync(ct);
    return Ok();
}

//Service Layer
public async Task DoWorkAsync(CancellationToken ct)
{
    await _repository.CallAsync(ct);
}

5. Unbounded parallelism with Task.WhenAll

Imagine you have too many Http requests that you want to process, its better to run all the calls at once in parallel rather than awaiting them one by one.

await Task.WhenAll(
    users.Select(u => ProcessUserAsync(u))
);

This is cool right!!😎, well not exactly. If the number of users is 5, its good, 500 a bit risky but when it is 50000 thats then disaster occurs cause you just told .NET “Start Everything at once”. A better option is to use SemaphoreSlim as it acts as a gatekeeper allowing only a certain number of requests to N requests at a time.

using var semaphore = new SemaphoreSlim(5);

await Task.WhenAll(
    users.Select(async u =>
    {
        await semaphore.WaitAsync();
        try
        {
            await _service.ProcessAsync(u);
        }
        finally
        {
            semaphore.Release();
        }
    })
);

6. Async Code inside Lock 🔒

An async code inside a lock is either illlegal or a serious design smell. We use lock for the purpose of allowing only a single thread to execute at the code at a time suppose you have used lock which is used for synchronous code, it will serialize async requests ( how does that sound?) . This can lead to deadlocks and destroy scalability.

lock (_sync)
{
    await _service.UpdateAsync(); //a very bad practice
}

Here too, SemaphoreSlim comes to the rescue. You can design it to allow only 1 operation to run at a time.

private readonly SemaphoreSlim _replaceLock = new(1, 1);

await _replaceLock.WaitAsync();
try
{
    await _service.UpdateAsync();
}
finally
{
    _replaceLock.Release();
}

Only one caller reaches here at a time and others wait until it finishes. Unlike lock this works across await.

7. Swallowing exceptions in async flows

Async Exceptions are easy to loose and once lost, production failure becomes invisible. Consider this code

try
{
    _ = ProcessAsync();
}
catch
{
    // this is empty
}

Here, failures are never logged and the system gets corrupted easily.

An Exception you don’t see is a bug you’ll meet in production.

If failures are acceptable we need to log it explicitly.

try
{
    await ProcessAsync();
}
catch (Exception ex)
{
    _logger.LogError(ex, "Processing failed");
    throw;
}

Having discussed all these pitfalls, one thing is for sure that async bugs don’t fail loudly, they hide behind low traffic, pass code reviews and surface only under real load. All of these issues look harmless in isolation, but together they quietly erode system stability. We must understand that the real mistake isn;t using asyn incorrectly it is beleiving that using async solves majority of the problems and gives scalability.

Hope you enjoyed reading this article and gained important insights from it. In case I have missed any other common pitfalls please let me know in the comments.

Connect with me: 🐦 X | 🔗 LinkedIn | 📸 Instagram | ▶️ YouTube

Thank you for being a part of the community

Before you go:

👉 Be sure to clap and follow the writer ️👏️️

👉 Follow us: **Linkedin| [Medium](https://medium.com/codetodeploy)**

👉 CodeToDeploy Tech Community is live on Discord — **Join now!**

Note: This Post may contain affiliate links.


메타데이터
post_id
26facf5dcb48
slug
async-pitfalls-in-asp-net-core-that-break-production-26facf5dcb48
url
https://medium.com/codetodeploy/async-pitfalls-in-asp-net-core-that-break-production-26facf5dcb48
canonical_url
https://medium.com/codetodeploy/async-pitfalls-in-asp-net-core-that-break-production-26facf5dcb48
author_url
https://medium.com/@kroshpan
status
ok
fetched_at
2026-06-22 12:55:45