Advanced

Developing an Asynchronous ASP.NET Core 10 Web API

End-to-end async flows, cancellation, streaming, external services and async anti-patterns.

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

  1. Module 1 — Understanding the Power of Asynchronous Code
  2. Module 2 — Implementing an End-to-End Async Flow
  3. Module 3 — Supporting Cancellation from the HTTP Request Down to the Database
  4. Module 4 — Supporting Asynchronous Streaming
  5. Module 5 — Integrating with External Services
  6. Module 6 — Testing, Observing, and Troubleshooting Async Web APIs
  7. Module 7 — Async Anti-patterns and Pitfalls
  8. Checklist — Async Best Practices
  9. 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 TypeExampleRecommended Approach
I/O-boundDB access, HttpClient, file readasync/await — releases the thread
CPU-bound (client)Heavy algorithm, UITask.Run to keep UI responsive
CPU-bound (server)Intensive computation, legacySynchronous 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 await becomes a state transition point
  • If the Task is already completed, no suspension — execution continues directly
  • If the Task is pending, the state machine captures current state and releases the thread
  • On completion, execution resumes potentially on a different thread

This is why the ThreadId may differ before and after an await — that is expected and normal.


Async Return Types

Return TypeWhen to UseNotes
voidEvent handlers onlyExceptions not observable, hard to test — avoid
TaskAsync operation with no return valueRepresents the method execution
Task<T>Async operation with return valueMost common — reference type
ValueTask<T>Often synchronous methods (e.g., cache)Struct — avoids heap allocation if synchronous
IAsyncEnumerable<T>Streaming sequencesModule 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 of ValueTask<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 MethodI/O?Async Version AvailableTo 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)NoneSynchronous only
Update()❌ (in-memory)NoneSynchronous only

AddAsync() exists only for special value generators that require DB access. In 99.9% of cases, use Add().


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.RequestAborted is a CancellationToken that fires when the client disconnects. The framework automatically manages the CancellationTokenSource. 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 covers TaskCanceledException (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:

PointReason
ValueTask (not Task)The IAsyncDisposable interface requires it
_disposed guardMethod must be idempotent — safe for multiple calls
GC.SuppressFinalize(this)Standard Dispose pattern — avoids redundant finalization
await using at consumerTriggers 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:

OperationRecommendation
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 effectsBusiness/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]
CriterionBufferedStreaming
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 caseSmall payloads, fast resultsThousands 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"]
ApproachBehaviorProblem
await Task.WhenAll✅ Threads released during I/O— Correct
Parallel.ForEach + .ResultBlocks N threadsThread pool exhaustion
Task.WaitAllBlocks calling thread1 thread wasted per request
Task.Run for I/OThread switch + state machineSlower 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:

TypeConcernsConfigured InException
Incoming request timeoutInbound requests to your APIAddRequestTimeouts() + [RequestTimeout]OperationCanceledException via HttpContext.RequestAborted
Resilience pipeline timeoutOutbound calls to external servicesAddStandardResilienceHandler()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), .Result no longer causes deadlock (no SynchronizationContext) — but it still blocks the thread and destroys scalability under load.

MethodDeadlock 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.Run does 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);
ContextSynchronizationContext?ConfigureAwait(false) useful?
ASP.NET (old, .NET Framework)✅ Yes — RequestContext✅ Yes — prevents deadlocks
WinForms / WPF✅ Yes — UIThread context✅ Yes — prevents deadlocks
ASP.NET Core❌ NoNo — unnecessary noise
Reusable libraryDepends 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

#PracticeDetail
1Async all the wayFrom controller/minimal API handler down to DB and HTTP calls. Never mix sync and async.
2Never .Result, .Wait(), .GetAwaiter().GetResult()In request handlers — thread pool starvation under load.
3Propagate CancellationToken everywhereController → Repository → EF Core → HttpClient.
4Cancel reads, think about writesReads: always cancel. Writes with external effects: business decision.
5Correct EF Core methodsToListAsync, SaveChangesAsync etc. for I/O. Synchronous Add(), Remove() for the change tracker.
6IAsyncEnumerable<T> for streamingWhen payloads are large or production is slow. Not by default — buffered responses have their place.
7No Task.Run on the serverLooks clean but doesn’t benefit scalability — actually degrades it.
8Don’t mutate shared state from concurrent tasksAggregate results after Task.WhenAll.
9Resilience with cancellation awarenessRetries and timeouts must respect the incoming CancellationToken.
10Dispose CancellationTokenSource and linked resourcesusing statements — IAsyncDisposable with await using.
11Measure with load testsA single request does not reveal scalability issues. Use bombardier + dotnet-counters.

Quick Reference Tables

Async EF Core vs Synchronous Methods

OperationAsync MethodSync MethodWhen to Use
List queryToListAsync(ct)Always async (I/O)
Single queryFirstOrDefaultAsync(ct)Always async (I/O)
Existence checkAnyAsync(ct)Always async (I/O)
CountCountAsync(ct)Always async (I/O)
SaveSaveChangesAsync(ct)Always async (I/O)
Add to change trackerAddAsync() ⚠️Add()Use synchronous Add()
Remove from change trackerRemove()Synchronous only
Update change trackerUpdate()Synchronous only
StreamingAsAsyncEnumerable()AsEnumerable()Async for large volumes

Sequential vs Concurrent — Comparison

CriterionSequential (foreach await)Concurrent (Task.WhenAll)
Total timeSum of all callsMaximum of a single call
Simplicity✅ Simple and predictableSlightly more complex
Thread usage1 thread per requestSame (async releases all threads)
Partial cancellation✅ Easy with linked tokenAlso needs linked token
Ideal useDependent callsIndependent 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

ToolInstallationUsage
dotnet-countersdotnet tool install --global dotnet-countersReal-time thread pool monitoring
dotnet-dumpdotnet tool install --global dotnet-dumpMemory dump capture and analysis
bombardierGitHub releasesHTTP load testing
clrthreadsCommand in dotnet-dump analyzeView 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

Interested in this course?

Contact us to book it or get a custom training plan for your team.