Introduction
ASP.NET Core Web API controllers should use async/await for all I/O-bound operations (database queries, HTTP calls, file reads) to keep threads free for handling other requests. An async controller action returns Task<IActionResult> or Task<T> and uses await on asynchronous methods. This does not make the operation faster for a single request but allows the server to handle more concurrent requests because threads are not blocked waiting for I/O. Synchronous controller actions block the thread pool and limit scalability under load.
Basic Async Controller
1using Microsoft.AspNetCore.Mvc;
2
3[ApiController]
4[Route("api/[controller]")]
5public class ProductsController : ControllerBase
6{
7 private readonly IProductRepository _repository;
8
9 public ProductsController(IProductRepository repository)
10 {
11 _repository = repository;
12 }
13
14 // Async action — thread released during database call
15 [HttpGet]
16 public async Task<IActionResult> GetAll()
17 {
18 var products = await _repository.GetAllAsync();
19 return Ok(products);
20 }
21
22 [HttpGet("{id}")]
23 public async Task<ActionResult<Product>> GetById(int id)
24 {
25 var product = await _repository.GetByIdAsync(id);
26 if (product == null)
27 return NotFound();
28 return product;
29 }
30
31 [HttpPost]
32 public async Task<IActionResult> Create([FromBody] ProductDto dto)
33 {
34 var product = await _repository.CreateAsync(dto);
35 return CreatedAtAction(nameof(GetById), new { id = product.Id }, product);
36 }
37}
Each await releases the thread back to the thread pool. While the database query runs, the thread handles other incoming requests.
Synchronous vs Async Comparison
1// WRONG — synchronous blocks the thread
2[HttpGet("sync")]
3public IActionResult GetProductsSync()
4{
5 // Thread is BLOCKED during database call
6 var products = _repository.GetAll(); // Synchronous
7 return Ok(products);
8}
9
10// CORRECT — async releases the thread
11[HttpGet("async")]
12public async Task<IActionResult> GetProductsAsync()
13{
14 // Thread is RELEASED during database call
15 var products = await _repository.GetAllAsync();
16 return Ok(products);
17}
Under load with 100 concurrent requests, the synchronous version blocks 100 threads. The async version needs far fewer threads because each is released during I/O waits.
Multiple Async Operations
1[HttpGet("dashboard")]
2public async Task<IActionResult> GetDashboard()
3{
4 // WRONG — sequential, each waits for the previous
5 // var users = await _userRepo.GetCountAsync();
6 // var orders = await _orderRepo.GetRecentAsync();
7 // var revenue = await _statsRepo.GetRevenueAsync();
8
9 // CORRECT — concurrent, all run at the same time
10 var usersTask = _userRepo.GetCountAsync();
11 var ordersTask = _orderRepo.GetRecentAsync();
12 var revenueTask = _statsRepo.GetRevenueAsync();
13
14 await Task.WhenAll(usersTask, ordersTask, revenueTask);
15
16 return Ok(new
17 {
18 UserCount = usersTask.Result,
19 RecentOrders = ordersTask.Result,
20 Revenue = revenueTask.Result,
21 });
22}
Start all independent tasks before awaiting any of them. Task.WhenAll completes when all three finish, cutting total time from sum to max.
Calling External APIs
1[ApiController]
2[Route("api/[controller]")]
3public class WeatherController : ControllerBase
4{
5 private readonly IHttpClientFactory _httpClientFactory;
6
7 public WeatherController(IHttpClientFactory httpClientFactory)
8 {
9 _httpClientFactory = httpClientFactory;
10 }
11
12 [HttpGet("{city}")]
13 public async Task<IActionResult> GetWeather(string city)
14 {
15 var client = _httpClientFactory.CreateClient("weather");
16
17 var response = await client.GetAsync($"/api/weather?q={city}");
18 if (!response.IsSuccessStatusCode)
19 return StatusCode((int)response.StatusCode);
20
21 var content = await response.Content.ReadAsStringAsync();
22 return Ok(content);
23 }
24}
25
26// Registration in Program.cs
27builder.Services.AddHttpClient("weather", client =>
28{
29 client.BaseAddress = new Uri("https://api.weather.com");
30 client.Timeout = TimeSpan.FromSeconds(10);
31});
Always use IHttpClientFactory — creating HttpClient directly causes socket exhaustion.
Async with Entity Framework Core
1public class ProductRepository : IProductRepository
2{
3 private readonly AppDbContext _context;
4
5 public ProductRepository(AppDbContext context)
6 {
7 _context = context;
8 }
9
10 public async Task<List<Product>> GetAllAsync()
11 {
12 return await _context.Products.ToListAsync();
13 }
14
15 public async Task<Product?> GetByIdAsync(int id)
16 {
17 return await _context.Products.FindAsync(id);
18 }
19
20 public async Task<Product> CreateAsync(ProductDto dto)
21 {
22 var product = new Product { Name = dto.Name, Price = dto.Price };
23 _context.Products.Add(product);
24 await _context.SaveChangesAsync();
25 return product;
26 }
27}
EF Core provides async versions of all database operations: ToListAsync(), FindAsync(), SaveChangesAsync(), FirstOrDefaultAsync(), etc.
Cancellation Tokens
1[HttpGet]
2public async Task<IActionResult> GetProducts(CancellationToken cancellationToken)
3{
4 // If the client disconnects, the token is cancelled
5 var products = await _context.Products
6 .ToListAsync(cancellationToken);
7
8 return Ok(products);
9}
10
11// Pass the token through to all async calls
12[HttpGet("search")]
13public async Task<IActionResult> Search(string query, CancellationToken ct)
14{
15 var dbResults = await _repository.SearchAsync(query, ct);
16 var apiResults = await _externalApi.SearchAsync(query, ct);
17 return Ok(new { dbResults, apiResults });
18}
ASP.NET Core passes a CancellationToken that triggers when the client disconnects. Passing it to async operations cancels unnecessary work.
Common Pitfalls
Using .Result or .Wait() in controllers: task.Result and task.Wait() block the thread synchronously, defeating the purpose of async and risking deadlocks. Always use await.
Async void controller actions: async void methods cannot be awaited, and unhandled exceptions crash the process. Controller actions must return Task<IActionResult>, not void.
Synchronous wrappers around async code: Task.Run(() => syncMethod()) offloads to a thread pool thread but does not free it — the thread is still blocked. Only use async for naturally async operations (I/O).
Not using CancellationToken: Without cancellation tokens, long-running database queries continue executing even after the client disconnects. Pass CancellationToken to all async methods.
Creating HttpClient directly: new HttpClient() inside controller actions causes socket exhaustion under load. Use IHttpClientFactory for proper connection pooling and lifetime management.
Summary
Use async Task<IActionResult> for all controller actions with I/O operations
await releases the thread during I/O, allowing the server to handle more concurrent requests
Use Task.WhenAll for independent operations instead of sequential await
Always pass CancellationToken to async methods to cancel work when clients disconnect
Use IHttpClientFactory for HTTP calls, never new HttpClient() directly
Never use .Result, .Wait(), or Task.Run() with synchronous code in ASP.NET Core controllers