Prerequisites: C# knowledge, experience with ASP.NET Core Web API and basic async/await concepts
Tools: Visual Studio 2026, SQL Server, .NET 10
Table of Contents
- Module 1 — Understanding the Power of Asynchronous Code
- Module 2 — Implementing an End-to-End Async Flow
- Module 3 — Supporting Cancellation from the HTTP Request Down to the Database
- Module 4 — Supporting Asynchronous Streaming
- Module 5 — Integrating with External Services
- Module 6 — Testing, Observing, and Troubleshooting Async Web APIs
- Module 7 — Async Anti-patterns and Pitfalls
- Checklist — Async Best Practices
- Quick Reference Tables
Module 1 — Understanding the Power of Asynchronous Code
Why Async for APIs?
Async is not about improving the performance of an individual request, but about vertical scalability: allowing a single server to handle more concurrent requests by releasing thread pool threads as soon as an I/O operation is pending.
Synchronous flow (problematic):
sequenceDiagram
participant Client
participant Thread A
participant Database
Client->>Thread A: HTTP Request
Thread A->>Database: SQL Query (I/O)
Note over Thread A: ⛔ Thread blocked, can do nothing
Database-->>Thread A: Result
Thread A-->>Client: HTTP Response
Asynchronous flow (optimal):
sequenceDiagram
participant Client
participant Thread A
participant Thread Pool
participant Database
Client->>Thread A: HTTP Request
Thread A->>Database: SQL Query (I/O) [await]
Thread A-->>Thread Pool: Released — available for other requests
Database-->>Thread Pool: Result available
Thread Pool->>Thread A (or B): Resume execution
Thread A (or B)-->>Client: HTTP Response
Result: With a single thread, multiple concurrent requests can be handled as soon as one is waiting on I/O.
I/O-bound vs CPU-bound Work
flowchart TD
A[What type of work?] --> B{Waiting for an operation?}
B -- Yes --> C[I/O-bound\nDB, network, files]
B -- No --> D{Intensive computation?}
D -- Yes --> E[CPU-bound\nHeavy algorithms]
D -- No --> F[Simple synchronous code]
C --> G[✅ async/await on the server\nReleases thread during wait]
E --> H[⚠️ DO NOT use Task.Run on the server\nSee module 7]
F --> I[✅ Synchronous code\nNo need for async]
| Work Type | Example | Recommended Approach |
|---|---|---|
| I/O-bound | DB access, HttpClient, file read | async/await — releases the thread |
| CPU-bound (client) | Heavy algorithm, UI | Task.Run to keep UI responsive |
| CPU-bound (server) | Intensive computation, legacy | Synchronous code — avoid Task.Run |
Inside the Compiler: The Async State Machine
When the compiler encounters an async keyword, it transforms the method into a state machine implementing IAsyncStateMachine.
stateDiagram-v2
[*] --> State0 : Async method called
State0 --> CheckTask : Code executed up to first await
CheckTask --> State0_continue : Task already completed (cache, immediate result)
CheckTask --> Suspended : Task not yet completed
Suspended --> ThreadPool : Releases current thread
ThreadPool --> State1 : I/O completed — resume (possibly another thread)
State1 --> [*] : Method complete, returns result
State0_continue --> [*] : No suspension
Key points about the state machine:
- Each
awaitbecomes a state transition point - If the
Taskis already completed, no suspension — execution continues directly - If the
Taskis pending, the state machine captures current state and releases the thread - On completion, execution resumes potentially on a different thread
This is why the
ThreadIdmay differ before and after anawait— that is expected and normal.
Async Return Types
| Return Type | When to Use | Notes |
|---|---|---|
void | Event handlers only | Exceptions not observable, hard to test — avoid |
Task | Async operation with no return value | Represents the method execution |
Task<T> | Async operation with return value | Most common — reference type |
ValueTask<T> | Often synchronous methods (e.g., cache) | Struct — avoids heap allocation if synchronous |
IAsyncEnumerable<T> | Streaming sequences | Module 4 |
When to use ValueTask<T>?
// ✅ Good usage: often synchronous (result in cache)
public async ValueTask<Book?> GetBookAsync(Guid id, CancellationToken ct)
{
if (_cache.TryGetValue(id, out var cached))
return cached; // No Task allocation — direct synchronous return
return await _context.Books.FirstOrDefaultAsync(b => b.Id == id, ct);
}
// ⚠️ Bad usage: always asynchronous — Task<T> is preferable
public async ValueTask<IEnumerable<Book>> GetBooksAsync(CancellationToken ct)
{
return await _context.Books.ToListAsync(ct); // Always async = unnecessary overhead
}
Rule: Use
ValueTask<T>only if measurements demonstrate a gain. The complexity ofValueTask<T>is not worth the avoided allocation for always-asynchronous methods.
Module 2 — Implementing an End-to-End Async Flow
Async Repository Pattern
IBooksRepository interface:
namespace Books.API.Services;
public interface IBooksRepository
{
Task<IEnumerable<Book>> GetBooksAsync(CancellationToken cancellationToken);
Task<Book?> GetBookAsync(Guid id, CancellationToken cancellationToken);
void AddBook(Book bookToAdd); // Change tracker — no I/O
Task<bool> SaveChangesAsync(CancellationToken cancellationToken);
IAsyncEnumerable<Book> GetBooksAsAsyncEnumerable(); // Streaming — Module 4
Task<BookCoverDto?> GetBookCoverAsync(string id, CancellationToken cancellationToken);
Task<IEnumerable<BookCoverDto>> GetBookCoversOneByOneAsync(Guid bookId, CancellationToken ct);
Task<IEnumerable<BookCoverDto>> GetBookCoversAfterWaitForAllAsync(Guid bookId, CancellationToken ct);
}
BooksRepository implementation:
public class BooksRepository(BooksContext context,
IHttpClientFactory httpClientFactory) : IBooksRepository
{
public async Task<IEnumerable<Book>> GetBooksAsync(CancellationToken cancellationToken)
{
return await _context.Books
.Include(b => b.Author)
.ToListAsync(cancellationToken); // ✅ I/O — await required
}
public async Task<Book?> GetBookAsync(Guid id, CancellationToken cancellationToken)
{
return await _context.Books
.Include(b => b.Author)
.FirstOrDefaultAsync(b => b.Id == id, cancellationToken);
}
public void AddBook(Book bookToAdd)
{
_context.Add(bookToAdd); // ✅ In-memory change tracker — NOT async
}
public IAsyncEnumerable<Book> GetBooksAsAsyncEnumerable()
{
return _context.Books
.Include(b => b.Author)
.AsAsyncEnumerable(); // Stream from the DB
}
public async Task<bool> SaveChangesAsync(CancellationToken cancellationToken)
{
return (await _context.SaveChangesAsync(cancellationToken) > 0);
}
}
Async Controller & Minimal API Endpoints
Controller:
[Route("api/books")]
[ApiController]
public class BooksController(IBooksRepository booksRepository,
IMapper mapper,
ILogger<BooksController> logger) : ControllerBase
{
[HttpGet()]
public async Task<IActionResult> GetBooks(CancellationToken cancellationToken)
{
var bookEntities = await _booksRepository.GetBooksAsync(cancellationToken);
return Ok(_mapper.Map<IEnumerable<BookDto>>(bookEntities));
}
[HttpGet("{id}", Name = "GetBook")]
public async Task<IActionResult> GetBook(Guid id, CancellationToken cancellationToken)
{
var bookEntity = await _booksRepository.GetBookAsync(id, cancellationToken);
if (bookEntity == null) return NotFound();
return Ok(_mapper.Map<BookDto>(bookEntity));
}
[HttpPost()]
public async Task<IActionResult> CreateBook(
[FromBody] BookForCreationDto bookForCreation,
CancellationToken cancellationToken)
{
var bookEntity = _mapper.Map<Book>(bookForCreation);
_booksRepository.AddBook(bookEntity); // Synchronous — change tracker
await _booksRepository.SaveChangesAsync(cancellationToken); // Async — DB I/O
var bookFromRepo = await _booksRepository.GetBookAsync(bookEntity.Id, cancellationToken);
return CreatedAtRoute("GetBook",
new { id = bookEntity.Id },
_mapper.Map<BookDto>(bookFromRepo));
}
}
Minimal API endpoints (Program.cs):
// Simple endpoint
app.MapGet("api/minimal/books", async (IBooksRepository repo,
IMapper mapper,
CancellationToken cancellationToken) =>
{
var bookEntities = await repo.GetBooksAsync(cancellationToken);
return Results.Ok(mapper.Map<IEnumerable<BookDto>>(bookEntities));
});
// TypedResults — recommended pattern in .NET 10
app.MapGet("api/minimal/books/{id:guid}",
async Task<Results<Ok<BookDto>, NotFound>> (Guid id,
IBooksRepository repo,
IMapper mapper,
CancellationToken cancellationToken) =>
{
var bookEntity = await repo.GetBookAsync(id, cancellationToken);
if (bookEntity == null) return TypedResults.NotFound();
return TypedResults.Ok(mapper.Map<BookDto>(bookEntity));
});
The async behavior is identical between Controller and Minimal API. The thread pool mechanics are the same. The choice is architectural, not related to async.
EF Core: What to Await and What Not To
flowchart LR
A[EF Core method] --> B{Accesses the DB?}
B -- Yes --> C["✅ ToListAsync(ct)\nFirstOrDefaultAsync(ct)\nSaveChangesAsync(ct)\nAnyAsync(ct), CountAsync(ct)"]
B -- No --> D["✅ Add() synchronous\nRemove() synchronous\nUpdate() synchronous\n(In-memory change tracker)"]
C --> E[Always pass the CancellationToken]
D --> F[No async version needed]
| EF Core Method | I/O? | Async Version Available | To Use |
|---|---|---|---|
ToListAsync(ct) | ✅ | ✅ | Always async |
FirstOrDefaultAsync(ct) | ✅ | ✅ | Always async |
SaveChangesAsync(ct) | ✅ | ✅ | Always async |
AnyAsync(ct) | ✅ | ✅ | Always async |
CountAsync(ct) | ✅ | ✅ | Always async |
Add() | ❌ (in-memory) | AddAsync() exists but… | Use synchronous Add() |
Remove() | ❌ (in-memory) | None | Synchronous only |
Update() | ❌ (in-memory) | None | Synchronous only |
AddAsync()exists only for special value generators that require DB access. In 99.9% of cases, useAdd().
Module 3 — Supporting Cancellation from the HTTP Request Down to the Database
Why Cancellation Matters
In production, clients disconnect constantly: navigation, tab closing, reverse proxy timeout, lost mobile signal. Without cancellation support:
- The server continues executing requests for responses nobody will ever read
- Wasted threads, DB connections, CPU
- Risk of data inconsistencies if a write operation partially completes
The two fundamental types:
classDiagram
class CancellationTokenSource {
+Cancel()
+CancelAfter(TimeSpan)
+Token : CancellationToken
+CreateLinkedTokenSource(...)$
}
class CancellationToken {
+IsCancellationRequested : bool
+ThrowIfCancellationRequested()
+Register(Action callback)
}
CancellationTokenSource --> CancellationToken : produces
CancellationTokenSource: creates and manages tokens — it’s the “STOP button”CancellationToken: lightweight value type passed to code that must listen for cancellation
In ASP.NET Core,
HttpContext.RequestAbortedis aCancellationTokenthat fires when the client disconnects. The framework automatically manages theCancellationTokenSource. You just need to accept the token and propagate it.
CancellationToken Propagation
flowchart TD
A["ASP.NET Core\nHttpContext.RequestAborted"] -->|automatic bind| B["Controller Action\nCancellationToken cancellationToken"]
B -->|pass as parameter| C["IBooksRepository\nGetBooksAsync(ct)"]
C -->|pass as parameter| D["EF Core\nToListAsync(ct)"]
D -->|passes to driver| E["DbCommand\nExecuteReaderAsync(ct)"]
E -->|cancels SQL query| F["✅ OperationCanceledException\npropagated upward"]
Complete propagation example:
// 1. Controller — accepts the token (auto-bound by ASP.NET Core)
[HttpGet()]
public async Task<IActionResult> GetBooks(CancellationToken cancellationToken)
{
var bookEntities = await _booksRepository.GetBooksAsync(cancellationToken);
return Ok(_mapper.Map<IEnumerable<BookDto>>(bookEntities));
}
// 2. Repository — receives and propagates the token
public async Task<IEnumerable<Book>> GetBooksAsync(CancellationToken cancellationToken)
{
return await _context.Books
.Include(b => b.Author)
.ToListAsync(cancellationToken); // EF Core cancels the SQL query if needed
}
// 3. Minimal API — same principle
app.MapGet("api/minimal/books", async (IBooksRepository repo,
IMapper mapper,
CancellationToken cancellationToken) => // Auto-bound
{
var bookEntities = await repo.GetBooksAsync(cancellationToken);
return Results.Ok(mapper.Map<IEnumerable<BookDto>>(bookEntities));
});
Timeout Policies with CancellationTokenSource
Use a CancellationTokenSource to impose a timeout on any async operation:
[HttpGet()]
public async Task<IActionResult> GetBooks(CancellationToken cancellationToken)
{
// Local 5-second timeout linked to the client disconnect token
using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
using var linkedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(
timeoutCts.Token,
cancellationToken); // Client disconnect token
try
{
var bookEntities = await _booksRepository
.GetBooksAsync(linkedTokenSource.Token);
return Ok(_mapper.Map<IEnumerable<BookDto>>(bookEntities));
}
catch (OperationCanceledException)
{
if (cancellationToken.IsCancellationRequested)
{
// Client disconnected — no response to send
throw;
}
else if (timeoutCts.IsCancellationRequested)
{
// 5-second timeout — return 504 or re-throw
throw;
}
}
return StatusCode(StatusCodes.Status500InternalServerError);
}
flowchart LR
A[timeoutCts\n5 seconds] --> C[LinkedTokenSource]
B[cancellationToken\nclient disconnect] --> C
C --> D[linkedTokenSource.Token]
D --> E{Who cancels?}
E -->|Timeout| F[Query too slow\nCancel]
E -->|Client disconnected| G[No one listening\nCancel]
Handling Cancellation Exceptions and Resource Cleanup
classDiagram
class Exception
class OperationCanceledException {
+CancellationToken Token
}
class TaskCanceledException
Exception <|-- OperationCanceledException
OperationCanceledException <|-- TaskCanceledException
Always catch
OperationCanceledException— this also coversTaskCanceledException(subclass thrown by EF Core).
[HttpGet("{id}", Name = "GetBook")]
public async Task<IActionResult> GetBook(Guid id, CancellationToken cancellationToken)
{
try
{
var bookEntity = await _booksRepository.GetBookAsync(id, cancellationToken);
if (bookEntity == null) return NotFound();
return Ok(_mapper.Map<BookDto>(bookEntity));
}
catch (OperationCanceledException)
{
// Log at Information level — not a system error
_logger.LogInformation("GetBook request was cancelled.");
return StatusCode(499); // Informal convention: client closed request
}
}
Default ASP.NET Core behavior: When an OperationCanceledException propagates uncaught, the framework logs the event and sends no response — which is correct since the client is already gone.
Resource cleanup with await using:
// await using guarantees DisposeAsync is called even if an exception is thrown
await using var writer = new BookExportWriter(exportPath);
await foreach (var book in books)
{
cancellationToken.ThrowIfCancellationRequested();
await writer.WriteBookAsync(book, cancellationToken);
}
// DisposeAsync called automatically — flush and stream close
IAsyncDisposable: Correct Implementation
public class BookExportWriter : IAsyncDisposable
{
private readonly StreamWriter _writer;
private bool _disposed;
public BookExportWriter(string filePath)
{
_writer = new StreamWriter(filePath, append: false);
}
public async Task WriteBookAsync(BookDto book, CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(_disposed, this);
await _writer.WriteLineAsync(
$"{book.Id},{book.Title}".AsMemory(),
cancellationToken);
}
public async ValueTask DisposeAsync() // ValueTask — interface requires this type
{
if (_disposed) return; // Guard against double-disposal
_disposed = true;
await _writer.DisposeAsync(); // Async flush + stream close
GC.SuppressFinalize(this); // No finalizer needed
}
}
Key points about IAsyncDisposable:
| Point | Reason |
|---|---|
ValueTask (not Task) | The IAsyncDisposable interface requires it |
_disposed guard | Method must be idempotent — safe for multiple calls |
GC.SuppressFinalize(this) | Standard Dispose pattern — avoids redundant finalization |
await using at consumer | Triggers DisposeAsync even on exception |
Cancellation and Data Consistency: When NOT to Cancel
flowchart TD
A[Client sends POST /books] --> B[DB transaction begins]
B --> C{Client disconnects\nduring SaveChangesAsync?}
C -- With cancellation --> D[Transaction rollback\nNo book created]
C -- Without cancellation --> E[Book created in DB ✅]
D --> F{Client retries?}
F -- Yes --> G[⚠️ Duplicate or confusion\nClient thinks it failed]
E --> H[✅ Consistency guaranteed\nEven if no one reads the response]
// ❓ Questionable for a write with visible external effects
[HttpPost()]
public async Task<IActionResult> CreateBook(
[FromBody] BookForCreationDto bookForCreation,
CancellationToken cancellationToken)
{
var bookEntity = _mapper.Map<Book>(bookForCreation);
_booksRepository.AddBook(bookEntity);
// Option 1: pass the token — risk of partial state if client disconnects
await _booksRepository.SaveChangesAsync(cancellationToken);
// Option 2 (critical write): do not cancel the save
// await _booksRepository.SaveChangesAsync(CancellationToken.None);
// External call can still be cancelled if client disconnected
// await _inventoryService.RegisterBookAsync(bookEntity.Id, cancellationToken);
}
General rule:
| Operation | Recommendation |
|---|---|
| Read (GET) | Always propagate the CancellationToken |
| Simple write (INSERT without external effects) | Often OK to cancel |
| Complex write (multi-step, external services) | Think carefully — let complete or use CancellationToken.None for some calls |
| Write with visible side effects | Business/product decision — not technical |
Module 4 — Supporting Asynchronous Streaming
Synchronous vs Asynchronous Iteration
flowchart LR
subgraph Synchronous
A1[foreach row in results] --> B1[Thread blocked\nbetween each row]
B1 --> C1[100K rows =\n100K blocks]
end
subgraph Asynchronous - IAsyncEnumerable
A2[await foreach row in results] --> B2[Thread released\nduring wait]
B2 --> C2[Thread pool available\nfor other requests]
end
With IAsyncEnumerable<T>, each MoveNextAsync() is a suspension point. The compiler generates a state machine — the same mechanism as ordinary async/await, applied to iteration.
Streaming with IAsyncEnumerable<T>
Repository — streaming method:
public IAsyncEnumerable<Book> GetBooksAsAsyncEnumerable()
{
return _context.Books
.Include(b => b.Author)
.AsAsyncEnumerable(); // EF Core streams row by row from the DB
}
Controller — streaming action:
[HttpGet("booksstream")]
public async IAsyncEnumerable<BookDto> StreamBooks(
[EnumeratorCancellation] CancellationToken cancellationToken)
// ↑ Tells the compiler which parameter receives the WithCancellation() token
{
await foreach (var bookFromRepository in
_booksRepository.GetBooksAsAsyncEnumerable()
.WithCancellation(cancellationToken))
{
await Task.Delay(500, cancellationToken); // Visual demo only — remove in prod
yield return _mapper.Map<BookDto>(bookFromRepository);
}
}
Minimal API — streaming:
app.MapGet("api/minimal/booksstream",
(IBooksRepository repo, IMapper mapper, CancellationToken ct) =>
{
return StreamBooks(repo, mapper, ct);
static async IAsyncEnumerable<BookDto> StreamBooks(
IBooksRepository repo, IMapper mapper,
[EnumeratorCancellation] CancellationToken ct)
{
await foreach (var book in repo.GetBooksAsAsyncEnumerable()
.WithCancellation(ct))
{
yield return mapper.Map<BookDto>(book);
}
}
});
Streaming Large Payloads and Real-Time Data
Pattern 1 — Large JSON export (IAsyncEnumerable):
[HttpGet("auditlogs/stream")]
public async IAsyncEnumerable<AuditLogDto> StreamAuditLogs(
[EnumeratorCancellation] CancellationToken ct)
{
// Items serialized and sent as they are read from DB
await foreach (var log in _repo.GetAuditLogsAsAsyncEnumerable().WithCancellation(ct))
{
yield return _mapper.Map<AuditLogDto>(log);
}
}
Pattern 2 — Direct CSV streaming to Response.Body:
[HttpGet("books/export.csv")]
public async Task ExportBooksAsCsv(CancellationToken cancellationToken)
{
Response.ContentType = "text/csv";
// leaveOpen: true — the framework owns the response stream
await using var writer = new StreamWriter(Response.Body, leaveOpen: true);
await writer.WriteLineAsync("Id,Title,Author");
await foreach (var book in _repo.GetBooksAsAsyncEnumerable()
.WithCancellation(cancellationToken))
{
await writer.WriteLineAsync($"{book.Id},{book.Title},{book.Author?.LastName}");
await writer.FlushAsync(); // Immediately pushes the line to the network
}
}
Pattern 3 — Real-time notifications with Channel<T>:
// Channel<T> = bridge between a producer (background service) and consumer (endpoint)
[HttpGet("notifications/stream")]
public async IAsyncEnumerable<NotificationDto> StreamNotifications(
[EnumeratorCancellation] CancellationToken cancellationToken)
{
await foreach (var notification in _notificationChannel.Reader
.ReadAllAsync(cancellationToken)) // Waits until an item is available
{
yield return notification;
}
// When client disconnects → the token cancels ReadAllAsync
}
Pattern 4 — Server-Sent Events (SSE):
[HttpGet("events")]
public async Task StreamSse(CancellationToken cancellationToken)
{
Response.ContentType = "text/event-stream";
Response.Headers["Cache-Control"] = "no-cache";
await foreach (var evt in _eventSource.ReadAllAsync(cancellationToken))
{
await Response.WriteAsync($"data: {JsonSerializer.Serialize(evt)}\n\n",
cancellationToken);
await Response.Body.FlushAsync(cancellationToken);
}
}
Streaming vs Buffered Responses: When to Use Which?
flowchart TD
A{What kind of response?} --> B{Large payload or\nslow production?}
B -- Yes --> C{Can infrastructure\nstream?}
C -- Yes --> D[✅ IAsyncEnumerable\nStreaming]
C -- No --> E[⚠️ Buffered anyway\ninfra buffers]
B -- No --> F[✅ Buffered Response\nTask of IActionResult]
D --> G[Advantage: low memory usage\nClient receives items progressively]
F --> H[Advantage: full error handling\n404/500 possible before sending]
| Criterion | Buffered | Streaming |
|---|---|---|
| Error handling | ✅ Before sending — 404, 500 possible | ⚠️ 200 already sent — partial data on error |
| Server memory | ⚠️ Everything in memory | ✅ Low footprint |
| Reverse proxies / CDN | ✅ Always works | ⚠️ May buffer and negate the benefit |
| Client complexity | ✅ Simple (response.json()) | ⚠️ Client must handle ReadableStream |
| Ideal use case | Small payloads, fast results | Thousands of rows, exports, live feeds |
Module 5 — Integrating with External Services
Async External Calls with HttpClient
Named client registration (Program.cs):
builder.Services.AddHttpClient("BookCoversAPI", client =>
{
client.BaseAddress = new Uri("https://localhost:52644");
}).AddStandardResilienceHandler(); // Resilience module — see below
Async call in the repository:
public async Task<BookCoverDto?> GetBookCoverAsync(string id,
CancellationToken cancellationToken)
{
var httpClient = _httpClientFactory.CreateClient("BookCoversAPI");
var response = await httpClient
.GetAsync($"/api/bookcovers/{id}", cancellationToken);
if (response.IsSuccessStatusCode)
{
return JsonSerializer.Deserialize<BookCoverDto>(
await response.Content.ReadAsStringAsync(cancellationToken),
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
return null;
}
Multiple Calls: Sequential vs Concurrent
sequenceDiagram
participant API as Books API
participant Ext as BookCovers API
rect rgb(255, 230, 230)
Note over API, Ext: Sequential — Total = sum of times
API->>Ext: Cover 1 (await)
Ext-->>API: Response 1
API->>Ext: Cover 2 (await)
Ext-->>API: Response 2
API->>Ext: Cover 3 (await)
Ext-->>API: Response 3
end
rect rgb(220, 255, 220)
Note over API, Ext: Concurrent (Task.WhenAll) — Total = max of times
API->>Ext: Cover 1
API->>Ext: Cover 2
API->>Ext: Cover 3
Ext-->>API: Response 2
Ext-->>API: Response 1
Ext-->>API: Response 3
Note over API: await Task.WhenAll — all received
end
Sequential Pattern:
public async Task<IEnumerable<BookCoverDto>> GetBookCoversOneByOneAsync(
Guid bookId, CancellationToken cancellationToken)
{
var httpClient = _httpClientFactory.CreateClient("BookCoversAPI");
var bookCovers = new List<BookCoverDto>();
var urls = new[]
{
$"/api/bookcovers/{bookId}-cover1",
$"/api/bookcovers/{bookId}-cover2",
$"/api/bookcovers/{bookId}-cover3",
};
foreach (var url in urls)
{
var response = await httpClient.GetAsync(url, cancellationToken);
if (response.IsSuccessStatusCode)
{
var cover = JsonSerializer.Deserialize<BookCoverDto>(
await response.Content.ReadAsStringAsync(cancellationToken),
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (cover != null) bookCovers.Add(cover);
}
}
return bookCovers;
}
Concurrent Pattern (Task.WhenAll):
public async Task<IEnumerable<BookCoverDto>> GetBookCoversAfterWaitForAllAsync(
Guid bookId, CancellationToken cancellationToken)
{
var httpClient = _httpClientFactory.CreateClient("BookCoversAPI");
var urls = new[]
{
$"/api/bookcovers/{bookId}-cover1",
$"/api/bookcovers/{bookId}-cover2",
$"/api/bookcovers/{bookId}-cover3",
};
// Launch all tasks without await — concurrent
var bookCoverTasks = urls
.Select(url => httpClient.GetAsync(url, cancellationToken))
.ToList();
// Wait for ALL to complete
var results = await Task.WhenAll(bookCoverTasks);
// ✅ Aggregate results AFTER Task.WhenAll — no shared mutation
var bookCovers = new List<BookCoverDto>();
foreach (var result in results)
{
if (result.IsSuccessStatusCode)
{
var cover = JsonSerializer.Deserialize<BookCoverDto>(
await result.Content.ReadAsStringAsync(cancellationToken),
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (cover != null) bookCovers.Add(cover);
}
}
return bookCovers;
}
Cancellation with Linked Token Sources
Scenario: if one call fails during sequential processing, cancel the remaining calls.
flowchart LR
A[Local CancellationTokenSource\nlocalCts] --> C[CreateLinkedTokenSource]
B[Incoming CancellationToken\nclient disconnect] --> C
C --> D[linkedTokenSource.Token]
D --> E[Passed to each call]
E --> F{Call fails?}
F -- Yes --> G["localCts.Cancel()"]
G --> H[linkedTokenSource.Token cancels]
H --> I[All remaining calls cancelled]
F -- No --> J[Continue]
public async Task<IEnumerable<BookCoverDto>> GetBookCoversOneByOneAsync(
Guid bookId, CancellationToken cancellationToken)
{
var httpClient = _httpClientFactory.CreateClient("BookCoversAPI");
var bookCovers = new List<BookCoverDto>();
// using guarantees resource release
using var localCts = new CancellationTokenSource();
using var linkedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(
localCts.Token, // Local cancellation (call failure)
cancellationToken); // Client disconnect cancellation
foreach (var url in _bookCoverUrls)
{
var response = await httpClient.GetAsync(url, linkedTokenSource.Token);
if (response.IsSuccessStatusCode)
{
var cover = JsonSerializer.Deserialize<BookCoverDto>(
await response.Content.ReadAsStringAsync(linkedTokenSource.Token),
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (cover != null) bookCovers.Add(cover);
}
else
{
localCts.Cancel(); // ⚡ Immediately cancels all remaining calls
}
}
return bookCovers;
}
Async Concurrency vs Parallel Processing
flowchart TD
A[Making multiple HTTP calls] --> B{Which approach?}
B --> C["✅ await Task.WhenAll\nasync calls"]
B --> D["❌ Parallel.ForEach\n+ .Result"]
B --> E["❌ Task.WaitAll\nblocks the thread"]
B --> F["❌ Task.Run\nfor I/O"]
C --> C1["Threads released during I/O wait\nOptimal scalability"]
D --> D1["Blocks N threads in the pool\nThread pool exhaustion under load"]
E --> E1["Blocks 1 thread per request\nWaste vs await Task.WhenAll"]
F --> F1["Thread switch + state machine\nNo gain, added overhead"]
| Approach | Behavior | Problem |
|---|---|---|
await Task.WhenAll | ✅ Threads released during I/O | — Correct |
Parallel.ForEach + .Result | Blocks N threads | Thread pool exhaustion |
Task.WaitAll | Blocks calling thread | 1 thread wasted per request |
Task.Run for I/O | Thread switch + state machine | Slower than synchronous |
Resilience with Microsoft.Extensions.Http.Resilience
// Program.cs — add resilience pipeline to the named client
builder.Services.AddHttpClient("BookCoversAPI", client =>
{
client.BaseAddress = new Uri("https://localhost:52644");
}).AddStandardResilienceHandler();
// AddStandardResilienceHandler automatically includes:
// - Per-attempt timeout
// - Retry with exponential backoff + jitter
// - Circuit breaker
Resilience pipeline:
flowchart LR
A[HTTP call] --> B[Per-attempt timeout]
B --> C{Success?}
C -- No --> D[Retry\nexponential backoff + jitter]
D --> E{Max retries?}
E -- No --> B
E -- Yes --> F[Circuit Breaker]
F --> G{Circuit open?}
G -- Yes --> H[❌ TimeoutRejectedException\nor BrokenCircuitException]
C -- Yes --> I[✅ Response returned]
Resilience Timeouts vs Client Disconnects
flowchart TD
A[Exception thrown] --> B{Which type?}
B -->|OperationCanceledException| C[Client disconnected\nHttpContext.RequestAborted token]
B -->|TimeoutRejectedException| D[Resilience pipeline\ntimeout exceeded]
C --> E["ASP.NET Core: no response sent\nClient is gone\n✅ Expected behavior"]
D --> F["Client still connected\nReturn 504 Gateway Timeout\n⚠️ Handle explicitly"]
try
{
var bookCover = await _booksRepository
.GetBookCoverAsync(id, cancellationToken);
return Ok(bookCover);
}
catch (TimeoutRejectedException)
{
// Resilience pipeline timeout — client still connected
return StatusCode(StatusCodes.Status504GatewayTimeout,
"The external service did not respond in time.");
}
catch (OperationCanceledException)
{
// Client disconnected — no one will read the response
_logger.LogInformation("Request cancelled by client.");
return StatusCode(499);
}
// ⚠️ Both catch blocks are REQUIRED
// TimeoutRejectedException and OperationCanceledException are in separate hierarchies
// One does not catch the other!
Combining Book + Covers in a Single Response
// Combined DTO
public class BookWithCoversDto : BookDto
{
public IEnumerable<BookCoverDto> BookCovers { get; set; } = new List<BookCoverDto>();
}
// Controller — two-step mapping with AutoMapper
[HttpGet("{id}", Name = "GetBook")]
[RequestTimeout(2000)]
public async Task<IActionResult> GetBook(Guid id, CancellationToken cancellationToken)
{
try
{
var bookEntity = await _booksRepository.GetBookAsync(id, cancellationToken);
if (bookEntity == null) return NotFound();
// Concurrent cover fetch (internal Task.WhenAll)
var bookCovers = await _booksRepository
.GetBookCoversAfterWaitForAllAsync(id, cancellationToken);
// Two-step mapping — AutoMapper doesn't natively support two sources
var mappedBook = _mapper.Map<BookWithCoversDto>(bookEntity);
_mapper.Map(bookCovers, mappedBook); // Inject covers into existing DTO
return Ok(mappedBook);
}
catch (OperationCanceledException)
{
_logger.LogInformation("GetBook request was cancelled.");
return StatusCode(499);
}
}
Module 6 — Testing, Observing, and Troubleshooting Async Web APIs
Load Testing: bombardier & dotnet-counters
Principle: Do not measure async on a single request — single-request times are nearly identical. Measure behavior under load.
Tool installation:
# dotnet-counters — real-time thread pool monitoring
dotnet tool install --global dotnet-counters
# bombardier — cross-platform HTTP load testing tool
# Download from GitHub releases — bombardier-windows-amd64.exe
# Place in %USERPROFILE%\.dotnet\tools (already in PATH)
Measurement workflow:
# 1. Find the API PID
dotnet-counters ps
# 2. Start thread pool monitoring
dotnet-counters monitor --process-id <PID> \
--counters System.Runtime[threadpool-thread-count,threadpool-queue-length]
# 3. Run the load test
bombardier -c 200 -d 30s https://localhost:5001/api/books
# Compare: async endpoint vs sync-over-async endpoint
# ✅ Async: stable thread count, queue length near 0
# ❌ Sync-over-async: thread count explodes, queue length rises, latency increases
flowchart LR
subgraph Correct async endpoint
A1[200 concurrent requests] --> B1[Stable thread count ~10-20]
B1 --> C1[Queue length ≈ 0]
C1 --> D1[Stable latency]
end
subgraph Sync-over-async antipattern
A2[200 concurrent requests] --> B2[Thread count rises rapidly]
B2 --> C2[Queue length explodes]
C2 --> D2[Latency +500% — Thread pool starvation]
end
Diagnosing Thread Pool Starvation
# 1. Capture a memory dump during starvation
dotnet tool install --global dotnet-dump
dotnet-dump collect --process-id <PID>
# 2. Analyze the dump
dotnet-dump analyze <dump-file>
# 3. Analysis command — blocked threads
> clrthreads
# Visible symptoms:
# - Dozens of "ThreadPool Worker" threads
# - Stacks showing .Result, .Wait() or GetAwaiter().GetResult()
# - Threads in "Blocked" state waiting on a Task
Configuring Incoming Request Timeouts
// Program.cs — timeout middleware registration
builder.Services.AddRequestTimeouts();
// ...
app.UseRequestTimeouts();
// NB: Different from resilience pipeline timeouts (outgoing calls)
// This protects consumers calling your API
// Per-endpoint via attribute
[HttpGet("{id}", Name = "GetBook")]
[RequestTimeout(2000)] // 2 seconds — triggers cancellation via HttpContext.RequestAborted
public async Task<IActionResult> GetBook(Guid id, CancellationToken cancellationToken)
{
// cancellationToken is bound to HttpContext.RequestAborted
// When timeout fires → token cancelled → OperationCanceledException propagated
var bookEntity = await _booksRepository.GetBookAsync(id, cancellationToken);
// ...
}
Two types of timeouts to distinguish:
| Type | Concerns | Configured In | Exception |
|---|---|---|---|
| Incoming request timeout | Inbound requests to your API | AddRequestTimeouts() + [RequestTimeout] | OperationCanceledException via HttpContext.RequestAborted |
| Resilience pipeline timeout | Outbound calls to external services | AddStandardResilienceHandler() | TimeoutRejectedException |
Unit Testing Async Repository Methods
// Fake repository — implements IBooksRepository with in-memory data
public class FakeBooksRepository : IBooksRepository
{
private readonly List<Book> _books = new();
public async Task<IEnumerable<Book>> GetBooksAsync(CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested(); // ✅ Respect the token like the real repo
return await Task.FromResult(_books.AsEnumerable()); // Task.FromResult for non-I/O methods
}
public async Task<Book?> GetBookAsync(Guid id, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
return await Task.FromResult(_books.FirstOrDefault(b => b.Id == id));
}
public void AddBook(Book bookToAdd) => _books.Add(bookToAdd);
public async Task<bool> SaveChangesAsync(CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
return await Task.FromResult(true);
}
// Other methods: throw NotImplementedException for unrelated tests
public IAsyncEnumerable<Book> GetBooksAsAsyncEnumerable() => throw new NotImplementedException();
}
xUnit tests:
public class BooksRepositoryTests
{
// Difference 1: async Task test methods (not void)
[Fact]
public async Task GetBooksAsync_ReturnsBooksFromRepo()
{
var fakeRepo = new FakeBooksRepository();
fakeRepo.AddBook(new Book { Id = Guid.NewGuid(), Title = "Sample Book" });
// Difference 2: await async repo methods
var books = await fakeRepo.GetBooksAsync(CancellationToken.None);
Assert.Single(books);
}
// Difference 3: test cancellation
[Fact]
public async Task GetBooksAsync_WithCancelledToken_ThrowsOperationCanceledException()
{
var fakeRepo = new FakeBooksRepository();
var cts = new CancellationTokenSource();
cts.Cancel(); // Cancel before the call
await Assert.ThrowsAsync<OperationCanceledException>(
() => fakeRepo.GetBooksAsync(cts.Token));
}
}
Integration Testing with WebApplicationFactory
# Create the test project
dotnet new xunit --name Books.API.Tests
dotnet sln add Books.API.Tests
dotnet add Books.API.Tests reference Books.API
dotnet add Books.API.Tests package Microsoft.AspNetCore.Mvc.Testing
// Expose Program class to test project (without exposing all internals)
// Add in Books.API — partial class with public modifier
public partial class Program { }
// Integration test — streaming endpoint
public class BooksEndpointsTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
public BooksEndpointsTests(WebApplicationFactory<Program> factory)
{
_factory = factory;
// WebApplicationFactory starts the API in memory
// No real HTTP server, no network ports
}
[Fact]
public async Task GetBooksStream_ReturnsStreamOfBooks()
{
var client = _factory.CreateClient();
// Test streaming — read by chunks
var response = await client.GetAsync("/api/books/booksstream",
HttpCompletionOption.ResponseHeadersRead); // Don't wait for full body
response.EnsureSuccessStatusCode();
var stream = await response.Content.ReadAsStreamAsync();
// Parse NDJSON or JSON array stream progressively...
Assert.NotNull(stream);
}
}
The Complete Request Lifecycle
flowchart TD
A[HTTP Request arrives] --> B{I/O needed?}
B -- No --> C[Synchronous code\nDirect response]
B -- Yes --> D[async/await at every step\nCancellationToken propagated]
D --> E{Result?}
E -->|Happy path| F[Await releases thread\nThread ID may change\nLow thread pool pressure\nHTTP response sent]
E -->|Cancellation / Timeout| G[OperationCanceledException thrown\nResources cleaned via using\nCancellation = expected event\nNot a system error]
E -->|Streaming| H[IAsyncEnumerable returned\nItems sent progressively\nClient disconnects → token cancels\nPartial data OK]
Module 7 — Async Anti-patterns and Pitfalls
Pitfall: Sync-over-async
The most destructive pattern in production. Calling .Result, .Wait(), or .GetAwaiter().GetResult() on a Task in a request handler.
// ❌ BAD — Sync-over-async
[HttpGet("syncoverasync")]
public IActionResult GetBooksSyncOverAsync()
{
// .Result blocks the calling thread until the Task completes
// Under load: thread pool exhaustion — latency explodes
var bookEntities = _booksRepository
.GetBooksAsync(CancellationToken.None)
.Result; // ⚠️ Blocks the thread!
return Ok(_mapper.Map<IEnumerable<BookDto>>(bookEntities));
}
// ❌ Variant — same problem
var result = GetBooksAsync().GetAwaiter().GetResult(); // Still blocking
// ✅ GOOD — Async all the way
[HttpGet()]
public async Task<IActionResult> GetBooks(CancellationToken cancellationToken)
{
var bookEntities = await _booksRepository.GetBooksAsync(cancellationToken);
return Ok(_mapper.Map<IEnumerable<BookDto>>(bookEntities));
}
Note: In ASP.NET Core (not old ASP.NET),
.Resultno longer causes deadlock (no SynchronizationContext) — but it still blocks the thread and destroys scalability under load.
| Method | Deadlock ASP.NET Core? | Blocks Thread? | Should Use? |
|---|---|---|---|
.Result | ❌ No | ✅ Yes | ❌ Never in a handler |
.Wait() | ❌ No | ✅ Yes | ❌ Never in a handler |
.GetAwaiter().GetResult() | ❌ No | ✅ Yes | ❌ Never in a handler |
await | ❌ No | ❌ No — releases thread | ✅ Always |
Pitfall: Async-over-sync
Wrapping synchronous CPU-bound code in Task.Run hoping for a scalability gain.
// ❌ BAD — Async-over-sync (illusion of async)
public async Task<int> GetBookPagesAsync(Guid bookId)
{
// Task.Run does NOT improve scalability for CPU-bound work on a server
return await Task.Run(() => new ComplexPageCalculator().Calculate(bookId));
}
flowchart LR
subgraph Pointless Task.Run
A[Thread A - Request] -->|Task.Run| B[Thread B - CPU Computation]
A -->|await - released| Pool
B -->|Computation done| C[Thread A or C - Resume]
Note1[2+ threads used\nContext switch overhead\nState machine allocated]
end
subgraph Direct synchronous code
D[Thread A - Request] --> E[Thread A - CPU Computation]
E --> F[Thread A - Response]
Note2[1 thread only\nNo overhead\nSimpler]
end
Task.Rundoes not save threads for CPU-bound work — thread B does the work while A is “released”, but B is busy. Same “thread-seconds” consumed, with additional overhead.
Pitfall: Modifying Shared State from Concurrent Tasks
// ❌ BAD — List<T> is not thread-safe
var bookCovers = new List<BookCoverDto>();
var tasks = urls.Select(async url =>
{
var response = await httpClient.GetAsync(url, ct);
var cover = await Deserialize(response);
bookCovers.Add(cover); // ⚠️ Race condition — possible corruption!
});
await Task.WhenAll(tasks);
// ✅ GOOD — Aggregate results AFTER Task.WhenAll
var bookCoverTasks = urls.Select(url => httpClient.GetAsync(url, ct)).ToList();
var results = await Task.WhenAll(bookCoverTasks);
// Sequential phase — no concurrent shared mutation
var bookCovers = new List<BookCoverDto>();
foreach (var result in results)
{
if (result.IsSuccessStatusCode)
{
var cover = await Deserialize(result);
bookCovers.Add(cover); // ✅ Sequential here — safe
}
}
ASP.NET Core has no SynchronizationContext — continuations run on arbitrary pool threads. Mutating shared state from concurrent tasks is inherently dangerous.
Pitfall: Fire-and-forget and Lost Exceptions
// ❌ BAD — Fire-and-forget
_ = _auditService.LogAccessAsync(bookId); // Task not awaited!
// If LogAccessAsync throws → silently ignored
// No crash, no log, no alert
// ✅ GOOD — Always await
await _auditService.LogAccessAsync(bookId);
Fire-and-forget code works in development — the logging call succeeds 99.9% of the time. It is only when the logging service is unavailable or the connection pool is exhausted that the exception is thrown and silently swallowed. This bug only manifests in production.
Pitfall: Async Lambdas in LINQ
var bookIds = new List<Guid> { ... };
// ❌ BAD — Select doesn't understand async
var books = bookIds.Select(async id =>
await _booksRepository.GetBookAsync(id, cancellationToken));
// Type of 'books': IEnumerable<Task<Book?>> — NOT IEnumerable<Book?>
// Tasks start immediately, but nobody awaits them!
// ✅ GOOD — Sequential processing
var books = new List<Book?>();
foreach (var id in bookIds)
{
books.Add(await _booksRepository.GetBookAsync(id, cancellationToken));
}
// ✅ GOOD — Concurrent processing (if desired)
var bookTasks = bookIds.Select(id =>
_booksRepository.GetBookAsync(id, cancellationToken));
var books = await Task.WhenAll(bookTasks);
ConfigureAwait(false): Why It’s Unnecessary in ASP.NET Core
// ❌ Pointless in ASP.NET Core — just noise
var books = await _context.Books.ToListAsync().ConfigureAwait(false);
// ✅ Simple and correct in ASP.NET Core
var books = await _context.Books.ToListAsync(cancellationToken);
| Context | SynchronizationContext? | ConfigureAwait(false) useful? |
|---|---|---|
| ASP.NET (old, .NET Framework) | ✅ Yes — RequestContext | ✅ Yes — prevents deadlocks |
| WinForms / WPF | ✅ Yes — UIThread context | ✅ Yes — prevents deadlocks |
| ASP.NET Core | ❌ No | ❌ No — unnecessary noise |
| Reusable library | Depends on consumer | ✅ Yes — defense in depth |
If you see
ConfigureAwait(false)everywhere in an ASP.NET Core project, it is likely a migration relic from old .NET Framework. It can be safely removed.
Checklist — Async Best Practices
| # | Practice | Detail |
|---|---|---|
| 1 | Async all the way | From controller/minimal API handler down to DB and HTTP calls. Never mix sync and async. |
| 2 | Never .Result, .Wait(), .GetAwaiter().GetResult() | In request handlers — thread pool starvation under load. |
| 3 | Propagate CancellationToken everywhere | Controller → Repository → EF Core → HttpClient. |
| 4 | Cancel reads, think about writes | Reads: always cancel. Writes with external effects: business decision. |
| 5 | Correct EF Core methods | ToListAsync, SaveChangesAsync etc. for I/O. Synchronous Add(), Remove() for the change tracker. |
| 6 | IAsyncEnumerable<T> for streaming | When payloads are large or production is slow. Not by default — buffered responses have their place. |
| 7 | No Task.Run on the server | Looks clean but doesn’t benefit scalability — actually degrades it. |
| 8 | Don’t mutate shared state from concurrent tasks | Aggregate results after Task.WhenAll. |
| 9 | Resilience with cancellation awareness | Retries and timeouts must respect the incoming CancellationToken. |
| 10 | Dispose CancellationTokenSource and linked resources | using statements — IAsyncDisposable with await using. |
| 11 | Measure with load tests | A single request does not reveal scalability issues. Use bombardier + dotnet-counters. |
Quick Reference Tables
Async EF Core vs Synchronous Methods
| Operation | Async Method | Sync Method | When to Use |
|---|---|---|---|
| List query | ToListAsync(ct) | — | Always async (I/O) |
| Single query | FirstOrDefaultAsync(ct) | — | Always async (I/O) |
| Existence check | AnyAsync(ct) | — | Always async (I/O) |
| Count | CountAsync(ct) | — | Always async (I/O) |
| Save | SaveChangesAsync(ct) | — | Always async (I/O) |
| Add to change tracker | AddAsync() ⚠️ | Add() | Use synchronous Add() |
| Remove from change tracker | — | Remove() | Synchronous only |
| Update change tracker | — | Update() | Synchronous only |
| Streaming | AsAsyncEnumerable() | AsEnumerable() | Async for large volumes |
Sequential vs Concurrent — Comparison
| Criterion | Sequential (foreach await) | Concurrent (Task.WhenAll) |
|---|---|---|
| Total time | Sum of all calls | Maximum of a single call |
| Simplicity | ✅ Simple and predictable | Slightly more complex |
| Thread usage | 1 thread per request | Same (async releases all threads) |
| Partial cancellation | ✅ Easy with linked token | Also needs linked token |
| Ideal use | Dependent calls | Independent calls |
Cancellation Exception Hierarchy
Exception
└── OperationCanceledException ← Always catch this type
└── TaskCanceledException ← Thrown by EF Core, HttpClient
Polly.Timeout.TimeoutRejectedException ← SEPARATE hierarchy — catch explicitly
Diagnostic Tools
| Tool | Installation | Usage |
|---|---|---|
dotnet-counters | dotnet tool install --global dotnet-counters | Real-time thread pool monitoring |
dotnet-dump | dotnet tool install --global dotnet-dump | Memory dump capture and analysis |
bombardier | GitHub releases | HTTP load testing |
clrthreads | Command in dotnet-dump analyze | View blocked threads in dump |
Return Types — Quick Decision
flowchart TD
A[Async method — which return type?] --> B{Returns a value?}
B -- No --> C[Task]
B -- Yes --> D{Often synchronous?\nFrequent cache hit?}
D -- Yes --> E["ValueTask<T>\nMeasure before optimizing"]
D -- No --> F["Task<T>\nDefault choice"]
F --> G{Stream of multiple items?}
G -- Yes --> H["IAsyncEnumerable<T>"]
G -- No --> F
Search Terms
asp.net · developing · asynchronous · core · web · api · apis · c# · .net · development · async · cancellation · pitfall · streaming · testing · concurrent · request · calls · data · exceptions · external · methods · repository · resilience