Skip to main content

Command Palette

Search for a command to run...

Your BackgroundService Has 2 Lifetimes, but Only One CancellationToken

Updated
10 min readView as Markdown
Your BackgroundService Has 2 Lifetimes, but Only One CancellationToken
P
Senior Software Engineer specialising in cloud architecture, distributed systems, and modern .NET development, with over two decades of experience designing and delivering enterprise platforms in financial, insurance, and high-scale commercial environments. My focus is on building systems that are reliable, scalable, and maintainable over the long term. I’ve led modernisation initiatives moving legacy platforms to cloud-native Azure architectures, designed high-throughput streaming solutions to eliminate performance bottlenecks, and implemented secure microservices environments using container-based deployment models and event-driven integration patterns. From an architecture perspective, I have strong practical experience applying approaches such as Vertical Slice Architecture, Domain-Driven Design, Clean Architecture, and Hexagonal Architecture. I’m particularly interested in modular system design that balances delivery speed with long-term sustainability, and I enjoy solving complex problems involving distributed workflows, performance optimisation, and system reliability. I enjoy mentoring engineers, contributing to architectural decisions, and helping teams simplify complex systems into clear, maintainable designs. I’m always open to connecting with other engineers, architects, and technology leaders working on modern cloud and distributed system challenges.

BackgroundService gives every worker the same deceptively simple entry point:

protected override Task ExecuteAsync(CancellationToken stopToken)

It encourages an equally simple implementation. Pass stopToken into every asynchronous operation, stop when it is cancelled, and allow the host to deal with the rest. That works for a timer which periodically refreshes an in memory value. Its incomplete for a worker which claims durable work, calls external services and records a result. This worker has at least two independent lifetimes:

  • the lifetime of the service instance.

  • the lifetime of each work item owned by that instance.

Those lifetimes often end for different reasons. A deployment can stop the service while a job remains valid. A lease can expire while the service remains healthy. One job can exceed its deadline without requiring the entire worker to shut down. Passing the host token everywhere combines these events into a single cancellation path. The code can stop, but it can no longer explain why it stopped or choose the correct recovery action.

What stopToken actually means here

The token passed to ExecuteAsync belongs to the host. Microsoft describes it as being triggered when StopAsync is called. In the current BackgroundService implementation, the base class cancels an internal token source and then waits for the task returned by ExecuteAsync.

The token therefore signals that the current application instance has begun shutting down. This can happen during a deployment, when a container receives SIGTERM, when the platform removes an instance during scale in, when a Windows Service is stopped, when a host failure policy terminates the application, or when application code calls IHostApplicationLifetime.StopApplication.

It doesnt mean that the current job has been rejected, that its lease has been revoked or that a user cancelled it. It doesnt identify whether a side effect has already happened. It also does not guarantee that the process will remain alive long enough to run arbitrary cleanup. There is another easily missed distinction. The token received by an override of StopAsync is not the token passed into ExecuteAsync:

public override async Task StopAsync(CancellationToken shutdownDeadlineToken)
{
    logger.LogInformation("Worker shutdown has started");
    await base.StopAsync(shutdownDeadlineToken);
}

The ExecuteAsync token says, "start stopping". The StopAsync parameter says, "the graceful shutdown process should no longer wait". HostOptions.ShutdownTimeout controls that window and defaults to 30 seconds. When it expires, the host can stop waiting even though the worker has not reached a safe point. A container platform may then terminate the process. Renaming the second parameter to shutdownDeadlineToken makes the difference visible. Most workers do not need to override StopAsync, the base implementation already signals ExecuteAsync and waits for it. When an override is necessary, confusing these two tokens produces shutdown code which either gives up immediately or waits without a useful bound.

The second lifetime belongs to the work

Think about a worker which reads a submission from a queue. The submission may have a visibility timeout, distributed lease or ownership row in a database. That ownership can end independently of the process. The worker now has several cancellation sources:

Source Meaning Normal response
Host stop This service instance is shutting down Reach a safe point, release or checkpoint the work, then exit
Lease loss This instance no longer owns the work Stop producing side effects and do not acknowledge completion
Job timeout This attempt exceeded its allowed duration Record or schedule the appropriate retry outcome
Business cancellation The work is no longer wanted Finish according to the domain’s cancellation rules

These sources can all stop execution, but they do not produce the same state transition.

A linked token remains useful. It gives the processor one cooperative stop signal regardless of which upstream event occurred. It should be created at the execution boundary rather than used as the only record of what happened. Once a linked token is cancelled, the processor generally sees only that linked token. It cannot reliably derive the original cause from the OperationCanceledException. The worker must retain the source tokens and classify the outcome itself.

A production worker

The following worker keeps the lifetimes separate. The queue API is illustrative, but the same shape applies to Azure Service Bus locks, database leases, blob leases and custom ownership tables.

internal sealed class SubmissionWorker(
    ISubmissionQueue queue,
    ISubmissionProcessor processor,
    ILogger<SubmissionWorker> logger)
    : BackgroundService
{
    private static readonly TimeSpan JobTimeout = TimeSpan.FromMinutes(5);
    private static readonly TimeSpan FinalisationTimeout = TimeSpan.FromSeconds(5);

    protected override async Task ExecuteAsync(CancellationToken stopToken)
    {
        try
        {
            await foreach (WorkLease lease in queue.ReadAllAsync(stopToken))
            {
                await ProcessLeaseAsync(lease, stopToken);
            }
        }
        catch (OperationCanceledException) when (stopToken.IsCancellationRequested)
        {
            logger.LogInformation("Submission worker stopped by the host");
        }
    }

    private async Task ProcessLeaseAsync(
        WorkLease lease,
        CancellationToken stopToken)
    {
        using var timeoutSource = new CancellationTokenSource(JobTimeout);
        using var workSource = CancellationTokenSource.CreateLinkedTokenSource(
            stopToken,
            lease.LeaseLostToken,
            timeoutSource.Token);

        try
        {
            await processor.ProcessAsync(
                lease.Submission,
                lease.Fence,
                workSource.Token);

            await RunFinalisationAsync(
                finalisationToken => lease.CompleteAsync(
                    lease.Fence,
                    finalisationToken));
        }
        catch (OperationCanceledException)
            when (lease.LeaseLostToken.IsCancellationRequested)
        {
            logger.LogWarning(
                "Lease {LeaseId} was lost while processing submission {SubmissionId}",
                lease.Id,
                lease.Submission.Id);

            // Another instance may now own the work. Do not complete or release it.
        }
        catch (OperationCanceledException)
            when (stopToken.IsCancellationRequested)
        {
            logger.LogInformation(
                "Releasing submission {SubmissionId} because this instance is stopping",
                lease.Submission.Id);

            await TryAbandonAsync(lease, "instance-stopping");
            throw;
        }
        catch (OperationCanceledException)
            when (timeoutSource.IsCancellationRequested)
        {
            logger.LogWarning(
                "Submission {SubmissionId} exceeded its processing deadline",
                lease.Submission.Id);

            await TryAbandonAsync(lease, "attempt-timed-out");
        }
        catch (Exception exception)
        {
            logger.LogError(
                exception,
                "Submission {SubmissionId} failed",
                lease.Submission.Id);

            await TryAbandonAsync(lease, "processing-failed");
        }
    }

    private async Task TryAbandonAsync(WorkLease lease, string reason)
    {
        try
        {
            await RunFinalisationAsync(
                finalisationToken => lease.AbandonAsync(
                    lease.Fence,
                    reason,
                    finalisationToken));
        }
        catch (Exception exception)
        {
            logger.LogWarning(
                exception,
                "Could not abandon lease {LeaseId}; it must expire naturally",
                lease.Id);
        }
    }

    private static async Task RunFinalisationAsync(
        Func<CancellationToken, Task> action)
    {
        using var finalisationSource =
            new CancellationTokenSource(FinalisationTimeout);

        await action(finalisationSource.Token);
    }
}

The processor receives a linked workToken, so any source can interrupt expensive work. The worker still retains the individual sources and selects the resulting state transition. Lease loss is checked before host shutdown deliberately. If both are signalled at almost the same time, protecting the ownership boundary takes priority. The instance must not release or complete work it may no longer own. This is a policy decision rather than an attempt to discover which cancellation happened a few microseconds earlier. The completion and abandonment operations receive a separate, short lived token. Reusing workSource.Token would cause them to cancel immediately because that token has already been cancelled on the paths where cleanup is required. Using CancellationToken.None or default avoids that immediate cancellation, but it permits cleanup to consume the entire shutdown window. A bounded independent token gives finalisation a chance without allowing it to wait indefinitely.

Cancellation does not enforce ownership

The LeaseLostToken improves responsiveness, but it cannot provide exclusivity. Cancellation in .NET is cooperative. A library may ignore the token, an external request may already have reached its server, or the token may be signalled immediately after a side effect succeeds. When several replicas can process the same durable work, ownership must also be enforced at the commit boundary. The example passes lease.Fence to both processing and completion. That fencing value can be an incrementing lease version, an ETag or another token checked by the system which records the result.

If instance A pauses long enough to lose its lease. Instance B acquires version 42 and starts processing. Instance A then resumes. Cancelling A’s token asks it to stop, but only a conditional write rejecting version 41 prevents it from committing stale output over B’s work. This is where process-local designs fail during scale out. A singleton ownership service, dictionary or CancellationTokenSource is unique only inside one process. With three replicas, there are three singletons and three unrelated host tokens. A distributed lease, conditional update or broker lock supplies the cross-process ownership boundary. Fencing should normally be combined with idempotency. A worker can complete an external side effect and be terminated before acknowledging its queue message. The next instance will receive the same work. No arrangement of cancellation tokens can remove that delivery gap.

Shutdown is not rollback

It is tempting to handle stopToken by rolling back whatever the worker was doing. In many workflows there is nothing useful to roll back. If the processor has sent an email, charged a payment provider or submitted data to another API, cancelling the local task does not reverse the remote operation. The safe shutdown action may instead be to checkpoint the last confirmed stage and allow the attempt to be reconciled later. Code in finally blocks and StopAsync is also best effort. It runs during an orderly host shutdown, but it will not run after every process crash, machine failure or forced container termination. Correctness must come from durable state, idempotent operations and lease expiry. Graceful shutdown reduces unnecessary recovery work; it cannot be the only recovery mechanism.

Do not detach work from ExecuteAsync

The task returned by ExecuteAsync represents the lifetime of the worker. BackgroundService.StopAsync waits for that task, subject to the shutdown deadline. Starting untracked work with _ = Task.Run(...) breaks the relationship. ExecuteAsync can finish while claimed jobs remain active, allowing the host to conclude that the service has stopped. The detached jobs can then be cut off by process termination without receiving a useful completion path.

If the worker processes jobs concurrently, retain and await every task. A bounded channel, Parallel.ForEachAsync or an explicit collection of in flight tasks can all work, provided the task returned by ExecuteAsync does not finish until the owned operations have reached their shutdown policy. Concurrency limits also need to match the lease and shutdown windows. Twenty five minute jobs cannot all drain during a 30 second platform termination allowance. Either the jobs must checkpoint, the lease must survive reassignment safely, or deployment must stop claiming new work before the process receives its final termination signal.

Test causes rather than cancellation alone

A test that merely cancels stopToken and asserts that ExecuteAsync returns proves little beyond the worker observing cancellation. The valuable tests verify the distinct state transition associated with each source of cancellation.

Host shutdown should stop the worker from claiming new work and cause it to release or checkpoint its current lease. A job timeout should abandon only that attempt without stopping the worker. Losing the lease should prevent the attempt from being acknowledged as complete, even if processing subsequently succeeds, while simultaneous lease loss and host shutdown should follow the chosen ownership priority consistently. Finalisation should have its own short deadline so that it cannot consume the entire graceful shutdown window, and an uncooperative processor should cause the host’s shutdown deadline to expire as expected. The tests should also prove that termination after an external side effect but before acknowledgement can be recovered safely through idempotency. The last scenario usually requires an integration test. It is the one most likely to expose whether the design survives scale out rather than merely stopping cleanly on a developer’s machine.

Give each lifetime its own signal

stopToken is an application lifetime signal. It should flow into queue reads and help active operations reach a safe point when the process is shutting down. It should not become the application’s universal explanation for why work ended. Give each durable work item an ownership signal. Give each attempt an explicit deadline. Preserve the original sources when creating a linked execution token, then classify cancellation at the worker boundary. Use a separate bounded token for finalisation and enforce ownership with durable fencing rather than process local state. The worker still has one convenient token for cooperative cancellation. It also retains enough information to behave correctly when deployments, timeouts, lease loss and scale out occur at the same time.