CancellationToken in Blazor: Stop Doing Work After the User Leaves
A user opens a page in your Blazor app, and a database query begins. Before the query finishes, they click away to another menu item.
In many Blazor applications, that original database query just keeps running. The component is no longer visible, the result is no longer needed, and the user is three pages away—yet your server is still allocating memory, executing SQL, consuming connection pool slots, and awaiting HTTP requests. When the operation finally completes, it delivers results to a component instance that has already been disposed.
This is precisely what CancellationToken is designed to solve. However, implementing cancellation in Blazor requires a clear distinction between disposable UI work and durable business operations.
The Problem in Plain Code
Consider a standard Blazor component reading from an EF Core service:
@page "/customers"
@inject CustomerService CustomerService
<h1>Customers</h1>
@if (customers is null)
{
<p>Loading...</p>
}
else
{
<ul>
@foreach (var customer in customers)
{
<li>@customer.Name</li>
}
</ul>
}
@code {
private IReadOnlyList<Customer>? customers;
protected override async Task OnInitializedAsync()
{
customers = await CustomerService.GetCustomersAsync();
}
}
And its corresponding service:
public sealed class CustomerService(IDbContextFactory<AppDbContext> dbContextFactory)
{
public async Task<IReadOnlyList<Customer>> GetCustomersAsync()
{
await using var db = await dbContextFactory.CreateDbContextAsync();
return await db.Customers
.OrderBy(c => c.Name)
.ToListAsync();
}
}
If GetCustomersAsync() takes three seconds, but the user navigates away after one second, the query continues executing to completion on the database server. Multiply this across dozens of concurrent users or rapid tab-switching, and you consume server throughput on work that delivers zero value.
Implementing Component-Lifetime Cancellation
To tie an asynchronous operation to the component’s lifecycle, implement IDisposable (or IAsyncDisposable), instantiate a CancellationTokenSource, and cancel it when Blazor disposes the component.
1. Update the Component
@page "/customers"
@implements IDisposable
@inject CustomerService CustomerService
<!-- Markup omitted for brevity -->
@code {
private readonly CancellationTokenSource _cts = new();
private IReadOnlyList<Customer>? customers;
protected override async Task OnInitializedAsync()
{
customers = await CustomerService.GetCustomersAsync(_cts.Token);
}
public void Dispose()
{
_cts.Cancel();
_cts.Dispose();
}
}
2. Pass the Token Down the Entire Call Chain
Passing the token into your service method does nothing unless every downstream asynchronous API accepts and honours it.
public sealed class CustomerService(IDbContextFactory<AppDbContext> dbContextFactory)
{
public async Task<IReadOnlyList<Customer>> GetCustomersAsync(CancellationToken cancellationToken = default)
{
// Pass to DbContext creation
await using var db = await dbContextFactory.CreateDbContextAsync(cancellationToken);
// Pass to EF Core async execution extensions
return await db.Customers
.AsNoTracking()
.OrderBy(c => c.Name)
.ToListAsync(cancellationToken);
}
}
Design Pattern Note: Always make
CancellationTokenoptional (= default) on service method signatures. This allows simple call-sites to omit it while giving framework-aware callers the ability to pass lifecycle tokens.
The same rule applies when calling HTTP endpoints via HttpClient:
public async Task<IReadOnlyList<Customer>> GetCustomersAsync(CancellationToken cancellationToken = default)
{
return await _httpClient.GetFromJsonAsync<List<Customer>>("api/customers", cancellationToken)
?? ];
}
Handling Exceptions and Logging Correctly
Cancellation in .NET is cooperative and functions by throwing an OperationCanceledException (or TaskCanceledException).
When using ComponentBase, Blazor automatically catches and suppresses OperationCanceledException when thrown from lifecycle methods like OnInitializedAsync or OnParametersSetAsync. You do not need empty try/catch blocks simply to prevent unhandled exception crashes during normal navigation.
However, you must handle cancellation properly when logging:
try
{
await LoadDashboardAsync(_cts.Token);
}
catch (OperationCanceledException) when (_cts.IsCancellationRequested)
{
// Log as Debug or Information—this is expected user behavior, not an application failure
_logger.LogDebug("Dashboard loading was canceled due to user navigation.");
}
catch (Exception ex)
{
// Real system failure
_logger.LogError(ex, "Failed to load dashboard data.");
}
Real-World Case 1: Search-As-You-Type (Debouncing + Cancellation)
Without cancellation, rapid typing creates race conditions where earlier, slower requests can complete after later requests, overwriting the UI with stale data.
To solve this, combine a debounce delay with cancellation of the prior search operation:
@page "/customer-search"
@implements IDisposable
@inject CustomerService CustomerService
<input type="search"
class="form-control"
placeholder="Search..."
@bind="searchText"
@bind:event="oninput"
@bind:after="SearchChangedAsync" />
@code {
private string searchText = string.Empty;
private IReadOnlyList<Customer> results = [];
private CancellationTokenSource? _searchCts;
private async Task SearchChangedAsync()
{
// Cancel and clean up any ongoing search operation
_searchCts?.Cancel();
_searchCts?.Dispose();
_searchCts = new CancellationTokenSource();
var token = _searchCts.Token;
if (string.IsNullOrWhiteSpace(searchText))
{
results = [];
return;
}
try
{
// 1. Debounce: Wait for user typing pause
await Task.Delay(300, token);
// 2. Query execution
results = await CustomerService.SearchAsync(searchText, token);
}
catch (OperationCanceledException) when (token.IsCancellationRequested)
{
// Quietly swallow—a newer search request superseded this one
}
}
public void Dispose()
{
_searchCts?.Cancel();
_searchCts?.Dispose();
}
}
Real-World Case 2: Handling Parameter Changes (OnParametersSetAsync)
When route parameters update on a component instance that is already rendered (e.g., navigating from /customers/100 to /customers/200), the component is not re-disposed. Instead, OnParametersSetAsync fires.
You should cancel the previous data load when parameters change:
@page "/customers/{Id:int}"
@code {
[Parameter] public int Id { get; set; }
private CancellationTokenSource? _loadCts;
private Customer? customer;
protected override async Task OnParametersSetAsync()
{
_loadCts?.Cancel();
_loadCts?.Dispose();
_loadCts = new CancellationTokenSource();
try
{
customer = await CustomerService.GetCustomerAsync(Id, _loadCts.Token);
}
catch (OperationCanceledException) when (_loadCts.IsCancellationRequested)
{
// Superseded by newer parameter navigation
}
}
}
Advanced: Linking Component and Operation Lifetimes
If a component needs to cancel individual internal operations (like search) and ensure all operations cancel if the component is disposed, use CreateLinkedTokenSource:
private readonly CancellationTokenSource _componentCts = new();
private CancellationTokenSource? _opCts;
private async Task RunOperationAsync()
{
_opCts?.Cancel();
_opCts?.Dispose();
// Links the component's lifetime with this specific operation
_opCts = CancellationTokenSource.CreateLinkedTokenSource(_componentCts.Token);
try
{
await CustomerService.ExecuteWorkAsync(_opCts.Token);
}
catch (OperationCanceledException)
{
// Triggers if either the component disposes OR a new operation starts
}
}
public void Dispose()
{
_componentCts.Cancel();
_opCts?.Cancel();
_opCts?.Dispose();
_componentCts.Dispose();
}
Built-In Cancellation: Blazor Router Navigation
If you execute pre-loading or authorization work inside the Blazor Router’s OnNavigateAsync hook, do not create a custom token source. The framework supplies a built-in token via NavigationContext.CancellationToken that automatically cancels if the user initiates a new navigation event:
<Router AppAssembly="@typeof(App).Assembly" OnNavigateAsync="OnNavigateAsync">
<!-- Configuration -->
</Router>
@code {
private async Task OnNavigateAsync(NavigationContext context)
{
if (context.Path.StartsWith("reports/", StringComparison.OrdinalIgnoreCase))
{
// Automatically canceled if user clicks away before load completes
await ReportService.PreloadMetadataAsync(context.CancellationToken);
}
}
}
Architectural Decision: When NOT to Cancel
The most important architectural consideration is distinguishing Disposable Work from Durable Work.
| Work Type | Examples | Strategy |
| Disposable Work | Fetching dashboards, search-as-you-type, grid pagination, calculating UI previews. | Cancel immediately on component disposal or parameter change. |
| Durable Work | Submitting a payment, creating an order, updating user permissions, triggering a database mutation. | Do NOT cancel via UI token. Use CancellationToken.None or offload to a background service/queue. |
The Core Architectural Question
If the user closes their browser tab 50 milliseconds after clicking this button, should the server stop executing the operation?
If NO: Do not bind the operation to the component’s
CancellationTokenSource. Component disposal should not break transactional database commits or state mutations. If an operation takes significant time and must execute reliably, hand it off to a background worker or queue (e.g.,BackgroundService, Hangfire, or MassTransit).
Clean Pattern Options for Reusability
If you find yourself duplicating CancellationTokenSource boilerplate across components, consider composition over heavy inheritance.
Option A: Disposable Wrapper Helper (Recommended)
public sealed class ComponentCancellation : IDisposable
{
private readonly CancellationTokenSource _cts = new();
public CancellationToken Token => _cts.Token;
public void Dispose()
{
_cts.Cancel();
_cts.Dispose();
}
}
Usage:
@code {
private readonly ComponentCancellation _compCan = new();
protected override async Task OnInitializedAsync()
{
data = await Service.LoadAsync(_compCan.Token);
}
public void Dispose() => _compCan.Dispose();
}
Option B: Common Abstract Base Class
public abstract class CancellableComponentBase : ComponentBase, IDisposable
{
private readonly CancellationTokenSource _cts = new();
protected CancellationToken ComponentToken => _cts.Token;
public virtual void Dispose()
{
_cts.Cancel();
_cts.Dispose();
GC.SuppressFinalize(this);
}
}
Summary Checklist
Pass Tokens Down: Ensure your app services, EF Core queries (
ToListAsync), andHttpClientcalls accept and honor the token.Dispose Correctly: Call
.Cancel()before.Dispose()onCancellationTokenSource.Avoid
StateHasChangedinDispose: Never invoke UI updates on components undergoing tear-down.Log Cleanly: Filter out
OperationCanceledExceptionfrom error logging pipelines whenIsCancellationRequestedis true.Differentiate Work: Bind queries and searches to component lifecycles; decouple transactional saves and state mutations.

Comments
Post a Comment