Skip to content
ExponaBLOG
.NET & ASP.NET CORE · DAY 038 / 100

async/await pitfalls in .NET: what developers should know

Async code that looks right but quietly hurts your API.

Expona Admin10 min read

async/await pitfalls in .NET

After five years building production platforms at scale, I've seen async code that looks perfectly fine on code review but silently tanks an API under real traffic. The tricky part: most of these bugs don't crash immediately. They manifest as timeouts, thread pool starvation, and background jobs that vanish when a request ends. In this post, I'll walk through the most dangerous async/await pitfalls in .NET and show you how to fix them before they reach production.

Never Block on Async: Task.Result and .Wait() Cause Thread Pool Starvation

The worst async mistake is not actually using async. When you block the current thread waiting for an async operation to complete, you're throwing away the whole point of async/await: freeing up threads to handle other requests.

Here's the classic trap. You have an async method, but the caller isn't async:

csharp
public class OrderService
{
    private readonly IPaymentGateway _gateway;
    
    // This method is NOT async, but it calls async code
    public Order CreateOrder(CreateOrderRequest request)
    {
        // BLOCKING: This locks the current thread until the task completes
        var paymentResult = _gateway.ProcessPaymentAsync(request.Amount).Result;
        
        if (!paymentResult.Success)
            throw new InvalidOperationException("Payment failed");
            
        var order = new Order { Amount = request.Amount, Status = "Paid" };
        return order;
    }
}

// In your controller:
[HttpPost("orders")]
public IActionResult CreateOrder([FromBody] CreateOrderRequest request)
{
    var order = _orderService.CreateOrder(request); // Blocks here
    return Ok(order);
}

The problem escalates fast. Under load, you'll see:

  • Thread pool threads pile up waiting for async operations that could run on the I/O thread pool
  • New requests can't get a thread, so they queue and timeout
  • Your CPU usage stays low while response times spike—classic starvation

The fix is straightforward: make the whole chain async.

csharp
public class OrderService
{
    private readonly IPaymentGateway _gateway;
    
    // NOW async all the way
    public async Task<Order> CreateOrderAsync(CreateOrderRequest request)
    {
        var paymentResult = await _gateway.ProcessPaymentAsync(request.Amount);
        
        if (!paymentResult.Success)
            throw new InvalidOperationException("Payment failed");
            
        var order = new Order { Amount = request.Amount, Status = "Paid" };
        return order;
    }
}

// In your controller:
[HttpPost("orders")]
public async Task<IActionResult> CreateOrder([FromBody] CreateOrderRequest request)
{
    var order = await _orderService.CreateOrderAsync(request); // Non-blocking
    return Ok(order);
}

If you absolutely can't make the caller async (rare), at minimum use ConfigureAwait(false) to avoid capturing the synchronization context. But that's a band-aid. The real solution is async all the way down.

Avoid async void: Exceptions Crash the Process Silently

I've debugged production issues for hours only to find an async void method swallowing exceptions. The moment an unhandled exception escapes an async void method, it's thrown back on the synchronization context and becomes an unobserved exception. In ASP.NET Core, that means it crashes the entire application pool.

Here's the danger:

csharp
public class UserService
{
    private readonly IEmailService _emailService;
    
    // DANGER: async void
    public async void SendWelcomeEmail(User user)
    {
        // If this throws, the exception goes nowhere
        await _emailService.SendAsync(new EmailMessage
        {
            To = user.Email,
            Subject = "Welcome",
            Body = "Thanks for signing up"
        });
    }
}

// In your registration controller:
[HttpPost("register")]
public async Task<IActionResult> Register([FromBody] RegisterRequest request)
{
    var user = new User { Email = request.Email };
    _userService.SendWelcomeEmail(user); // Fire and forget... but exception will crash the app
    return Ok(user);
}

If the email service times out or the SMTP server is down, you get an unobserved TaskScheduler.UnobservedTaskException. The exception doesn't surface anywhere your error handling can catch it. The app crashes.

The rule: async void is only for event handlers. Everywhere else, return Task or Task<T>.

csharp
public class UserService
{
    private readonly IEmailService _emailService;
    
    // Better: returns Task so caller can await or handle exceptions
    public async Task SendWelcomeEmailAsync(User user)
    {
        await _emailService.SendAsync(new EmailMessage
        {
            To = user.Email,
            Subject = "Welcome",
            Body = "Thanks for signing up"
        });
    }
}

// But since we're not awaiting it (fire-and-forget), we need a background queue
// See the next section

Event handlers are the exception because they can't return Task:

csharp
// This is the only acceptable use of async void
button.Click += async (sender, e) =>
{
    await SomeAsyncOperationAsync();
};

Do Not Over-Parallelize: Task.WhenAll on Thousands of Operations Exhausts Resource Pools

Async makes parallelism easy, which is both a gift and a trap. Firing off 1,000 concurrent database queries with Task.WhenAll will exhaust your connection pool within seconds.

I've seen this in production APIs querying related data:

csharp
public class OrderDetailService
{
    private readonly IOrderRepository _orderRepo;
    private readonly ICustomerRepository _customerRepo;
    private readonly IInventoryRepository _inventoryRepo;
    
    public async Task<List<OrderDetail>> GetOrderDetailsAsync(int[] orderIds)
    {
        // DANGER: If orderIds has 1,000 items, this tries to open 1,000 concurrent DB connections
        var orders = await Task.WhenAll(
            orderIds.Select(id => _orderRepo.GetByIdAsync(id))
        );
        
        var customers = await Task.WhenAll(
            orders.Select(o => _customerRepo.GetByIdAsync(o.CustomerId))
        );
        
        var inventory = await Task.WhenAll(
            orders.SelectMany(o => o.LineItems.Select(li => li.ProductId))
                  .Distinct()
                  .Select(pid => _inventoryRepo.GetByProductIdAsync(pid))
        );
        
        return BuildDetails(orders, customers, inventory);
    }
}

Under load, you'll see connection pool timeouts: "Timeout expired. The timeout period elapsed prior to obtaining a connection from the pool."

The solution is to batch or limit concurrency:

csharp
public class OrderDetailService
{
    private readonly IOrderRepository _orderRepo;
    private const int BatchSize = 50; // Limit concurrent operations
    
    public async Task<List<OrderDetail>> GetOrderDetailsAsync(int[] orderIds)
    {
        var allOrders = new List<Order>();
        
        // Process in batches
        for (int i = 0; i < orderIds.Length; i += BatchSize)
        {
            var batch = orderIds.Skip(i).Take(BatchSize).ToList();
            var orders = await Task.WhenAll(
                batch.Select(id => _orderRepo.GetByIdAsync(id))
            );
            allOrders.AddRange(orders);
        }
        
        return allOrders.ToList();
    }
}

Or use a helper for limiting concurrency:

csharp
public static class TaskExtensions
{
    public static async Task<T[]> WhenAllLimited<T>(
        this IEnumerable<Task<T>> tasks,
        int maxConcurrency)
    {
        var results = new List<T>();
        var semaphore = new SemaphoreSlim(maxConcurrency);
        
        var wrappedTasks = tasks.Select(async t =>
        {
            await semaphore.WaitAsync();
            try
            {
                return await t;
            }
            finally
            {
                semaphore.Release();
            }
        });
        
        return await Task.WhenAll(wrappedTasks);
    }
}

// Usage:
var orders = await orderIds
    .Select(id => _orderRepo.GetByIdAsync(id))
    .WhenAllLimited(maxConcurrency: 20);

Fire-and-Forget is a Lie: Background Work Tied to a Request Dies With It

The biggest async mistake I see: treating fire-and-forget as a way to handle background work. If a task isn't awaited and isn't tied to a hosted service or background queue, it will be abandoned when the HTTP request ends.

csharp
public class OrderController : ControllerBase
{
    private readonly IOrderService _orderService;
    private readonly IEmailService _emailService;
    
    [HttpPost("orders")]
    public async Task<IActionResult> CreateOrder([FromBody] CreateOrderRequest request)
    {
        var order = await _orderService.CreateOrderAsync(request);
        
        // RISKY: Email task is not awaited. ASP.NET may shut down the app
        // before this completes.
        _ = _emailService.SendOrderConfirmationAsync(order);
        
        return Ok(order);
    }
}

What happens: The response returns immediately, but the email task is still running. If the app recycles, the background task is killed mid-operation. You never know if the email was sent.

The right way is to hand the work to a persistent queue or hosted service:

csharp
public class OrderController : ControllerBase
{
    private readonly IOrderService _orderService;
    private readonly IBackgroundJobQueue _jobQueue;
    
    [HttpPost("orders")]
    public async Task<IActionResult> CreateOrder([FromBody] CreateOrderRequest request)
    {
        var order = await _orderService.CreateOrderAsync(request);
        
        // Enqueue the job durably
        await _jobQueue.EnqueueAsync(new SendOrderConfirmationJob { OrderId = order.Id });
        
        return Ok(order);
    }
}

// Hosted service processes the queue
public class BackgroundJobProcessor : BackgroundService
{
    private readonly IBackgroundJobQueue _queue;
    private readonly IEmailService _emailService;
    private readonly IOrderRepository _orderRepo;
    
    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        while (!ct.IsCancellationRequested)
        {
            var job = await _queue.DequeueAsync(ct);
            if (job is SendOrderConfirmationJob emailJob)
            {
                var order = await _orderRepo.GetByIdAsync(emailJob.OrderId);
                await _emailService.SendOrderConfirmationAsync(order);
            }
        }
    }
}

Or use a real queue like Redis or RabbitMQ for even better durability:

csharp
public class OrderController : ControllerBase
{
    private readonly IOrderService _orderService;
    private readonly IConnectionMultiplexer _redis;
    
    [HttpPost("orders")]
    public async Task<IActionResult> CreateOrder([FromBody] CreateOrderRequest request)
    {
        var order = await _orderService.CreateOrderAsync(request);
        
        // Durably push to Redis queue
        var db = _redis.GetDatabase();
        await db.ListRightPushAsync(
            "email:queue",
            JsonSerializer.Serialize(new { OrderId = order.Id, Type = "OrderConfirmation" })
        );
        
        return Ok(order);
    }
}

Common Mistakes

  • Using .Wait() or .Result in middleware: Middleware that blocks on async operations starves threads for actual requests. Always flow async through middleware.
  • Forgetting ConfigureAwait(false) in libraries: If you're writing a library, always use await task.ConfigureAwait(false) to avoid capturing the ASP.NET synchronization context unnecessarily.
  • Assuming Task.Run improves performance: Wrapping sync code in Task.Run just moves blocking to a thread pool thread. It doesn't help. Only use Task.Run to offload CPU-bound work.
  • Not testing under load: These bugs hide in unit tests. Load test with tools like k6 or Apache Bench to surface thread pool starvation and connection pool exhaustion.
  • Mixing sync and async repositories: If your data layer has both sync and async methods, the sync ones become tempting shortcuts. Remove them. Force async all the way.

Key Takeaways

  • Never block on async with .Result or .Wait(). Make the entire call chain async to free threads for other requests.
  • Never use async void except in event handlers. Unhandled exceptions in async void crash the app.
  • Limit parallelism with SemaphoreSlim or batch operations when using Task.WhenAll to avoid exhausting connection pools.
  • Background work must be queued durably. Fire-and-forget tasks die with the request. Use a hosted service or external queue.
  • Test under realistic load. Connection pool exhaustion and thread starvation don't show up until traffic hits.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles