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:

Razor CSHTML

@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:

C#

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

Razor CSHTML

@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.

C#

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 CancellationToken optional (= 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:

C# 

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:

C#

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:

Razor CSHTML

@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:

C#

@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:

C#

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:

Razor CSHTML

<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 TypeExamplesStrategy
Disposable WorkFetching dashboards, search-as-you-type, grid pagination, calculating UI previews.Cancel immediately on component disposal or parameter change.
Durable WorkSubmitting 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)

C#

public sealed class ComponentCancellation : IDisposable
{
    private readonly CancellationTokenSource _cts = new();

    public CancellationToken Token => _cts.Token;

    public void Dispose()
    {
        _cts.Cancel();
        _cts.Dispose();
    }
}

Usage:

Razor CSHTML

@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

C#

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

  1. Pass Tokens Down: Ensure your app services, EF Core queries (ToListAsync), and HttpClient calls accept and honor the token.

  2. Dispose Correctly: Call .Cancel() before .Dispose() on CancellationTokenSource.

  3. Avoid StateHasChanged in Dispose: Never invoke UI updates on components undergoing tear-down.

  4. Log Cleanly: Filter out OperationCanceledException from error logging pipelines when IsCancellationRequested is true.

  5. Differentiate Work: Bind queries and searches to component lifecycles; decouple transactional saves and state mutations.

Comments

Popular posts from this blog

Customizing PWA Manifest and Icons for a Polished User Experience 🚀

Offline-First Strategy with Blazor PWAs: A Complete Guide 🚀

Yes, Blazor Server can scale!