Create your own
Lesson illustration

Implementing Cancellable Asynchronous APIs Without Sync-over-Async

Good to see you again. In the previous lesson, you established a defensible ASP.NET Core pipeline: routing selects endpoint metadata, authentication establishes the caller, authorization evaluates access rules, and exception handling wraps downstream execution.

This lesson follows one protected request through that pipeline and into application and data-access code. The goal is practical: implement a portfolio read operation that stops doing useful work when its client disconnects, while keeping the path genuinely asynchronous. This matters under load: abandoned Angular requests, browser navigations, retries, and API-client timeouts should not leave database and application threads doing work whose result nobody can receive.

Plan for roughly 45 minutes, including a small implementation pass in your capstone.


1. Cancellation is a request-abandonment signal, not a forced stop

A CancellationToken is a cooperative signal. It tells code, “the caller no longer wants this work,” but it does not forcibly terminate a thread, kill arbitrary CPU work, or guarantee that a database command has stopped at a precise instant.

In an ASP.NET Core controller action, the framework can bind a CancellationToken parameter to HttpContext.RequestAborted. That token is cancelled when the connection underlying the HTTP request is aborted.

Model Binding in ASP.NET Core | Microsoft Learn

Read “Model Binding in ASP.NET Core” from Microsoft Learn to confirm how a controller action receives the request-abort signal without manually accessing HttpContext.

In the “Special data types” section, read the CancellationToken passage. Focus on the fact that this token represents an aborted underlying HTTP connection, and that its purpose is to cancel long-running asynchronous work.

The consequences are operationally important:

SituationWhat the token meansAppropriate response
User navigates away from an Angular pageThe browser may abort outstanding HTTP callsStop pending database, HTTP, file, or delay operations where possible
API client manually cancels a requestThe result is no longer wantedAvoid subsequent queries, mapping, or external calls
A database operation already committedThe client may not receive the responseDo not assume cancellation means “nothing happened”
CPU-intensive loop is runningNothing stops automaticallyCheck the token at sensible work boundaries
Downstream API accepts a tokenIt can observe cancellation itselfPass the original token to it

The last two rows are the key. Cancellation works only when every layer cooperates.

The diagram shows a client cancellation travelling through ASP.NET Core and EF Core to the database provider, allowing a running SQL query to be stopped when the provider supports cancellation.

For a portfolio query, cancellation is especially useful when a user changes filters quickly or leaves the portfolio page while the API is still querying SQL Server. Without propagation, the HTTP connection may be gone, yet the API can continue consuming connection-pool capacity, database CPU, and application resources.

A useful interview formulation is:

A request cancellation token is a cooperative signal representing lost interest in a response. I accept it at the API boundary and pass it to every downstream asynchronous operation that supports it, particularly database and network I/O. That prevents unnecessary work after a client disconnects, but it does not reverse work that may already have committed.


2. Treat the token as part of the request contract

For a request-driven operation, make the token visible at every asynchronous boundary:

  1. The controller accepts the token supplied by ASP.NET Core.
  2. The application service accepts and forwards it.
  3. The repository accepts and forwards it to EF Core.
  4. Any external client, file operation, delay, or loop also receives it if its API supports cancellation.

Keep CancellationToken as the final parameter. This is the conventional .NET signature shape, makes calls readable, and allows static analysis to detect missed propagation.

Here is a compact capstone implementation. The endpoint’s full HTTP semantics and validation will be strengthened in the next lesson; for now, focus on the asynchronous request path.

[ApiController]
[Route("api/portfolios")]
[Authorize]
public sealed class PortfoliosController : ControllerBase
{
    private readonly IPortfolioSummaryService _portfolioSummaryService;

    public PortfoliosController(
        IPortfolioSummaryService portfolioSummaryService)
    {
        _portfolioSummaryService = portfolioSummaryService;
    }

    [HttpGet("{id:guid}/summary")]
    public async Task<ActionResult<PortfolioSummaryResponse>> GetSummary(
        [FromRoute] Guid id,
        CancellationToken cancellationToken)
    {
        var summary = await _portfolioSummaryService.GetSummaryAsync(
            id,
            cancellationToken);

        return summary is null ? NotFound() : Ok(summary);
    }
}

The token is not created by your controller, and the controller should not try to cancel it. ASP.NET Core owns the underlying request-abort signal. Your responsibility is to pass it onward.

Define the service boundary explicitly:

public interface IPortfolioSummaryService
{
    Task<PortfolioSummaryResponse?> GetSummaryAsync(
        Guid portfolioId,
        CancellationToken cancellationToken);
}

Then forward the token into the repository call:

public sealed class PortfolioSummaryService : IPortfolioSummaryService
{
    private readonly IPortfolioRepository _portfolioRepository;

    public PortfolioSummaryService(
        IPortfolioRepository portfolioRepository)
    {
        _portfolioRepository = portfolioRepository;
    }

    public async Task<PortfolioSummaryResponse?> GetSummaryAsync(
        Guid portfolioId,
        CancellationToken cancellationToken)
    {
        cancellationToken.ThrowIfCancellationRequested();

        var portfolio = await _portfolioRepository.GetByIdAsync(
            portfolioId,
            cancellationToken);

        if (portfolio is null)
        {
            return null;
        }

        // This check matters if response construction becomes non-trivial.
        cancellationToken.ThrowIfCancellationRequested();

        return PortfolioSummaryResponse.From(portfolio);
    }
}

Finally, let EF Core observe the same token:

public interface IPortfolioRepository
{
    Task<Portfolio?> GetByIdAsync(
        Guid id,
        CancellationToken cancellationToken);
}

public sealed class EfPortfolioRepository : IPortfolioRepository
{
    private readonly PortfolioDbContext _dbContext;

    public EfPortfolioRepository(PortfolioDbContext dbContext)
    {
        _dbContext = dbContext;
    }

    public Task<Portfolio?> GetByIdAsync(
        Guid id,
        CancellationToken cancellationToken)
    {
        return _dbContext.Portfolios
            .AsNoTracking()
            .SingleOrDefaultAsync(
                portfolio => portfolio.Id == id,
                cancellationToken);
    }
}

Notice that EfPortfolioRepository.GetByIdAsync does not need the async keyword. It returns the Task produced by EF Core directly and does no extra work. That is fully asynchronous and avoids an unnecessary compiler-generated async state machine.

The application service does need async, because it awaits the repository and then performs a decision and mapping step.


3. Forward cancellation to the operation that can actually stop

The most useful place for a token is at the actual I/O boundary. Passing a token only through controller and service signatures is incomplete if the repository silently drops it.

The same pattern applies to the APIs you will use in the capstone:

Work typePreferAvoid
EF Core queryToListAsync(cancellationToken), SingleOrDefaultAsync(cancellationToken)Calling the overload without a token
EF Core writeSaveChangesAsync(cancellationToken)SaveChanges() in a request path
Outbound HTTPSendAsync(request, cancellationToken)Calling an overload that ignores cancellation
Artificial or polling delayTask.Delay(delay, cancellationToken)Thread.Sleep(...) or token-free delays
File I/OAsync file APIs with a token where availableSynchronous disk reads in an API request
CPU loop over many recordsThrowIfCancellationRequested() periodicallyWaiting until every item is processed

For CPU-bound work, checking on each item can be excessive if the loop is extremely tight. Instead, choose a meaningful batch boundary:

foreach (var batch in holdingBatches)
{
    cancellationToken.ThrowIfCancellationRequested();

    foreach (var holding in batch)
    {
        CalculateHoldingMetrics(holding);
    }
}

Do not add Task.Run merely to make ordinary I/O “asynchronous.” EF Core and HttpClient already expose asynchronous I/O APIs. Wrapping I/O in Task.Run spends an additional thread-pool worker without making the database or network operation more scalable.

For a short in-memory mapping such as PortfolioSummaryResponse.From(portfolio), a cancellation check is not always necessary. It becomes worth adding once the step includes substantial computation, a large collection transformation, image processing, report generation, or repeated calls.

Stop wasting server resources by properly using CancellationToken in .NET

Watch “Stop wasting server resources by properly using CancellationToken in .NET” by Nick Chapsas. It demonstrates the practical failure mode: a client aborts a request, but server work continues because the token was not forwarded.

Watch the uncancelled request to see why abandoned work still reaches the database. Then watch the propagated token, focusing on the controller, interface, repository, and database-command boundaries through which the same token is passed.


4. Cancellation and sync-over-async are separate problems

Code can accept and forward a CancellationToken yet still be harmful under load if it blocks waiting for asynchronous work.

Sync-over-async means synchronously waiting on a Task that should have been awaited. The most common forms are:

// Do not do this in an ASP.NET Core request path.
var portfolio = _portfolioRepository
    .GetByIdAsync(id, cancellationToken)
    .Result;
// Do not do this either.
_portfolioRepository
    .GetByIdAsync(id, cancellationToken)
    .Wait();
// Also synchronous blocking, despite looking more deliberate.
var portfolio = _portfolioRepository
    .GetByIdAsync(id, cancellationToken)
    .GetAwaiter()
    .GetResult();

Each blocks the current thread while the database or network operation is pending. Under concurrent traffic, blocked request threads accumulate. The thread pool may then struggle to provide workers for continuations, logging, authorization, and new requests. In some synchronization contexts, blocking can also cause deadlocks; ASP.NET Core usually does not install the older ASP.NET synchronization context, but that does not make thread-pool blocking acceptable.

The correct version is straightforward:

var portfolio = await _portfolioRepository.GetByIdAsync(
    id,
    cancellationToken);

Use await all the way to the request boundary. In practice, this leads to a clear rule:

If a method starts asynchronous work and needs its result, make the caller asynchronous and await it. Do not convert asynchronous waiting into synchronous blocking just to preserve a synchronous signature.

This does not mean every method must be marked async. A method that simply returns an existing Task, such as the repository method shown earlier, can return it directly. The issue is blocking, not the absence of the async keyword.

Also avoid these adjacent mistakes:

  • Do not call .Result inside a constructor, middleware, controller, authorization handler, or DI registration factory that performs I/O.
  • Do not use async void for application methods. Use Task or Task<T> so errors and cancellation remain observable.
  • Do not pass CancellationToken.None merely to quiet an analyzer warning. That explicitly opts out of the request’s cancellation signal.
  • Do not start fire-and-forget work from a request using its request token. Once the request ends, that work has no valid request lifetime. Durable background processing needs an explicit design, which you will cover later with hosted services and queue-backed work.

ConfigureAwait(false) is not a remedy for sync-over-async. In modern ASP.NET Core application code it is usually unnecessary; it does not justify calling .Result or .Wait().


5. Use CA2016 as a guardrail, then review the gaps it cannot see

The .NET analyzer rule CA2016 identifies a common propagation failure: a method receives a token but calls another method that accepts one without passing it.

CA2016: Forward the CancellationToken parameter to methods that take one (code analysis) - .NET | Microsoft Learn

Read Microsoft Learn’s explanation of CA2016. It is a focused analyzer rule that helps keep cancellation propagation from regressing during routine feature work and code review.

Read the rule description, then continue through “How to fix violations” and Examples 1 and 2. Notice that the recommended fix forwards the received token; explicitly passing default or CancellationToken.None only suppresses the warning and should be a deliberate, documented exception.

Enable the rule at warning level in the capstone’s .editorconfig:

[*.cs]
dotnet_diagnostic.CA2016.severity = warning

For example, this service has accepted cancellation but accidentally discarded it:

public async Task<Portfolio?> GetPortfolioAsync(
    Guid id,
    CancellationToken cancellationToken)
{
    return await _portfolioRepository.GetByIdAsync(id);
}

The correct code is:

public async Task<Portfolio?> GetPortfolioAsync(
    Guid id,
    CancellationToken cancellationToken)
{
    return await _portfolioRepository.GetByIdAsync(
        id,
        cancellationToken);
}

CA2016 is valuable but not magical. It cannot reliably prove that:

  • your own method signatures expose cancellation consistently;
  • a third-party library truly honours its token;
  • a long CPU loop performs cancellation checks;
  • cancellation is safe relative to a business side effect;
  • an operation has been made asynchronous rather than hidden behind .Result.

Use the analyzer as a safety net, then trace the call chain during review.


6. Handle cancellation deliberately; do not disguise it as success

Most token-aware APIs signal cancellation by throwing OperationCanceledException. TaskCanceledException derives from it, so catch the base type if you need to catch cancellation specifically.

At the application-service layer, you often do not need to catch it at all. Let it propagate out of the operation so all layers stop naturally. If you add logging or cleanup, filter the exception to the token you actually own:

try
{
    return await _portfolioRepository.GetByIdAsync(
        portfolioId,
        cancellationToken);
}
catch (OperationCanceledException)
    when (cancellationToken.IsCancellationRequested)
{
    _logger.LogInformation(
        "Portfolio summary request was cancelled for {PortfolioId}",
        portfolioId);

    throw;
}

Do not catch cancellation and return an empty successful response:

// Misleading: an empty portfolio list is not the same as an aborted request.
catch (OperationCanceledException)
{
    return [];
}

The client requested cancellation; it cannot use a replacement response reliably, and returning 200 OK would blur a meaningful operational event into ordinary business data.

Similarly, do not convert a client-aborted request into a 400 Bad Request. When the underlying connection is gone, there is typically no client available to receive an HTTP response anyway. Your main job is to stop work and record cancellation at an appropriate log level, rather than create noisy error logs.

This slightly updates the audit middleware from the previous lesson. A request abort should not be logged as an unexpected server error:

try
{
    await _next(context);
}
catch (OperationCanceledException)
    when (context.RequestAborted.IsCancellationRequested)
{
    _logger.LogInformation(
        "Request cancelled by client: {Method} {Path}",
        context.Request.Method,
        context.Request.Path);

    throw;
}
catch (Exception exception)
{
    _logger.LogError(
        exception,
        "Request failed before a response was produced");

    throw;
}

There is an important write-operation nuance. Suppose a client cancels during a future POST that saves a portfolio change. The cancellation may arrive just after SQL Server has committed but before the response reaches the client. A retry could therefore duplicate the business action unless the API has an idempotency or concurrency strategy. Forward cancellation for resource efficiency, but do not claim that request cancellation provides transactional rollback or exactly-once behavior.


7. Implement and verify the capstone path

Apply this focused checklist to the portfolio tracker now:

  1. Add a CancellationToken cancellationToken parameter to one controller action that performs an EF Core read.
  2. Add the same final parameter to its application-service and repository contracts.
  3. Forward the exact token into EF Core’s asynchronous query method.
  4. Search the affected request path for .Result, .Wait(), and .GetAwaiter().GetResult(). Replace each with await or restructure the caller to return a Task.
  5. Enable CA2016 as a warning and resolve any new findings by forwarding the token.
  6. Add a temporary development-only diagnostic delay, if useful, and cancel the request from Postman or the browser. Confirm through logs that work after the awaited delay or query does not execute.

A small unit-level check can verify propagation without requiring a real database. A hand-written test double records the token received by the repository, and the test asserts that the service passed through the same token. Later, you will formalize this kind of verification with xUnit, integration tests, and WebApplicationFactory.

For code review, inspect the operation in this order:

Review pointExpected evidence
Controller boundaryA final CancellationToken parameter
Service contractToken present and forwarded unchanged
Repository or clientToken passed to EF Core or HTTP async overload
Async behaviorEvery required task is awaited or returned as Task
Blocking scanNo .Result, .Wait(), or .GetAwaiter().GetResult()
Cancellation handlingNo conversion of cancellation into fake successful data
LoggingClient cancellation is distinguishable from a server failure

Key takeaways

  • ASP.NET Core binds a controller CancellationToken to the request-abort signal.
  • Cancellation is cooperative: it becomes effective only when downstream database, HTTP, delay, file, or CPU-bound code observes the token.
  • Pass the token from controller to service to repository, with CancellationToken as the final parameter.
  • Use token-aware EF Core methods such as SingleOrDefaultAsync, ToListAsync, and SaveChangesAsync.
  • Keep request processing async all the way through. .Result, .Wait(), and .GetAwaiter().GetResult() block threads and can cause thread-pool starvation under load.
  • CA2016 helps detect missed propagation, but review still needs to examine I/O calls, CPU loops, and business-side-effect safety.
  • A cancelled request is not a successful empty response, and it does not guarantee that a partially completed write was rolled back.

Next, you will turn this operational skeleton into a more complete portfolio REST endpoint: appropriate HTTP status codes, explicit request validation, and consistent Problem Details errors.

Can't find a good explanation? Sign up and we'll make it for you

Sign up