Skip to content
~/rifqi_abrory

Pages

Home
About
Stack
Experience
Projects
Writing
Contact
All projects

Projects

Avrist PINTAR — Agent Portal
Avrist e-Application
Avrist Academy — LMS CMS
Avrist e-Payment
PRUHub
SIYAP

Articles

Angular signals for everyday admin pages
Typed reactive forms in Angular, without the any
Consistent API errors in ASP.NET Core with ProblemDetails
Passing CancellationToken all the way down in ASP.NET Core
Graceful shutdown for Go HTTP services
Idempotency keys: making payment APIs safe to retry
A spam-resistant contact form with Next.js Server Actions
Offline-friendly forms in React Native: drafts and a submit queue
Request validation and error handling in Spring Boot 3
Keyset pagination in SQL Server: when OFFSET gets slow

Actions

Switch to dark mode
Read in Bahasa Indonesia
Copy email address
All articles

Passing CancellationToken all the way down in ASP.NET Core

Why a cancelled HTTP request should stop its database query too, and the small habits that make it happen.

3 min read

On this page

Work nobody is waiting for

A user opens a report page, gets impatient, and navigates away. The browser closes the connection, but the server keeps going: the controller still waits on a heavy SQL query, maps thousands of rows, and serializes a response that nobody will read.

On a quiet day that is harmless. Under load, abandoned requests compete with real ones for database connections and thread-pool time. ASP.NET Core already knows when the client is gone; it exposes that through a CancellationToken. The work is to pass that token everywhere.

Where the token comes from

Add a CancellationToken parameter to an action and ASP.NET Core binds it to HttpContext.RequestAborted automatically:

[HttpGet("reports/monthly")]
public async Task<IActionResult> Monthly(int year, int month, CancellationToken ct)
{
    var report = await reports.BuildMonthlyAsync(year, month, ct);
    return Ok(report);
}

Minimal APIs work the same way:

app.MapGet("/reports/monthly", (int year, int month, IReportService reports, CancellationToken ct) =>
    reports.BuildMonthlyAsync(year, month, ct));

Pass it to everything that awaits

The token only helps if it reaches the code that actually waits. That usually means EF Core, HttpClient, and any other I/O:

public sealed class ReportService(AppDbContext db, HttpClient rates) : IReportService
{
    public async Task<MonthlyReport> BuildMonthlyAsync(int year, int month, CancellationToken ct)
    {
        var from = new DateTime(year, month, 1);
        var to = from.AddMonths(1);

        var premiums = await db.Payments
            .Where(p => p.PaidAt >= from && p.PaidAt < to)
            .GroupBy(p => p.ProductCode)
            .Select(g => new { Product = g.Key, Total = g.Sum(p => p.Amount) })
            .ToListAsync(ct);

        var fx = await rates.GetFromJsonAsync<FxRates>("rates/latest", ct);

        return MonthlyReport.Create(premiums, fx);
    }
}

When the token is cancelled, EF Core asks SQL Server to cancel the running command, and HttpClient aborts the outgoing call. Both throw OperationCanceledException.

Simple rules that keep it consistent

  1. Every async method that does I/O takes a CancellationToken, usually as the last parameter. Name it ct or cancellationToken, and stay consistent within the codebase.
  2. Do not default it with = default in application code. A default parameter makes it easy to forget, and the compiler will no longer remind you. Libraries are the exception.
  3. Never pass CancellationToken.None just to make it compile. If a call truly must not be cancelled, write a comment explaining why.
  4. Loops check the token. For CPU-heavy loops call ct.ThrowIfCancellationRequested() once per iteration or batch.

Things that should not be cancelled

Some work must finish even if the user leaves, such as writing an audit record or committing a payment that the provider already accepted. Do not pass the request token there:

// The provider has already charged the customer; recording it must not depend on the browser.
await payments.MarkAsPaidAsync(paymentId, CancellationToken.None);

For longer work, hand it off to a background queue or a hosted service with its own lifetime, instead of running it inside the request.

Do not log cancellations as errors

A cancelled request is normal, not a bug. If your global error handling logs every exception as an error, abandoned requests will flood the logs. Filter them out when the client is gone:

catch (OperationCanceledException) when (httpContext.RequestAborted.IsCancellationRequested)
{
    // Client disconnected; nothing to report.
}

Adding your own timeout

Sometimes you also want a hard limit, regardless of the client. Link both:

using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeout.CancelAfter(TimeSpan.FromSeconds(10));

var result = await slowService.RunAsync(timeout.Token);

The operation now stops when the client leaves or after ten seconds, whichever comes first.

Summary

  • ASP.NET Core gives you RequestAborted for free; take it as a parameter.
  • Pass it to EF Core, HttpClient, and every awaited I/O call.
  • Deliberately skip it only for work that must complete, and say why.
  • Treat cancellations as normal, not as errors in your logs.