Skip to content

Case 2: The Scalability Killer - Sync vs. Async ​

The Scenario πŸ“ ​

  • System: An ASP.NET Core web API designed to serve hundreds or thousands of users at the same time.
  • Problem: An endpoint for getting order details works fine with a few users, but when traffic increases, the app slows down and starts showing "503 Service Unavailable" errors.

The Problematic Code (The Synchronous Way) ​

A developer used to older ways of coding might write this:

csharp
[HttpGet("{id}")]
public ActionResult<OrderDetailsViewModel> GetOrderDetails(int id)
{
    // The mistake is here: using .FirstOrDefault() instead of .FirstOrDefaultAsync()
    var order = _context.Orders
                        .AsNoTracking()
                        .FirstOrDefault(o => o.Id == id);

    if (order == null)
    {
        return NotFound();
    }

    // ... map to a view model and return
    return Ok(viewModel);
}

The Hidden Bottleneck 🧐 ​

  1. The Thread Pool: Think of your ASP.NET Core application as a small workshop with a limited number of workers (called "threads"). Each worker can handle one user request at a time.

  2. What is I/O?: I/O (Input/Output) operations are tasks where your application has to wait for something external. The most common examples are waiting for a database to return data, waiting for another API to respond, or reading a file from a disk.

  3. The Problem with Synchronous I/O:

    • When you call a synchronous method like .FirstOrDefault(), the worker (the thread) is blocked. It does nothing but wait for the database to send back the data.
    • This is like a worker who orders a part and then just stands at the door waiting for the delivery, instead of doing other tasks.
    • When many users make requests at the same time, all the workers get blocked waiting for the database. When a new request comes in, there are no free workers to handle it. The workshop grinds to a halt, and the application crashes.

The Solution: Use async/await for All I/O βœ… ​

  • The Logic: async/await is a special feature in C#. When a task encounters an await on an I/O operation (like await _context.FirstOrDefaultAsync(...)), it does the following:

    1. It sends the request to the database.
    2. It releases the worker (the thread) back to the workshop, so it can handle other user requests.
    3. When the database finally sends the data back, any free worker in the workshop can pick it up and finish the original request.
  • The Optimized Code:

    csharp
    [HttpGet("{id}")]
    public async Task<ActionResult<OrderDetailsViewModel>> GetOrderDetails(int id)
    {
        // Use the Async version for all I/O operations
        var order = await _context.Orders
                                  .AsNoTracking()
                                  .FirstOrDefaultAsync(o => o.Id == id);
    
        if (order == null)
        {
            return NotFound();
        }
    
        // ...
        return Ok(viewModel);
    }

The Results ✨ ​

  1. Much Higher Throughput: A single worker can now manage hundreds of requests that are "waiting" for data, instead of being blocked by just one. This dramatically increases the number of simultaneous users the app can handle.
  2. Better Scalability: The application won't crash under heavy load. It might get a little slower, but it will keep running.

Conclusion:

  • Golden Rule: "Async all the way." Whenever you perform an operation that might involve waiting (calling a database, an API, or reading a file), always use the async version of the method and await it.
  • Not using async/await for I/O is one of the most serious performance mistakes you can make in modern web applications.