Large Azure Storage jobs are easy to start and much harder to stop correctly.
A small application might upload a few files, download an object, or copy data between containers and finish within seconds. A production data process can be very different. It may process thousands of blobs, move large objects, copy data between storage accounts, or run as a background worker for several hours.
The difficult part is not only starting that work. The application also needs to know what should happen when the job is cancelled, the application is shutting down, a timeout occurs, a user presses Stop, or a deployment replaces the running process.
A clean stop means the application stops accepting new work, allows safe operations to finish when possible, cancels work that should not continue, records enough information to resume later, and avoids leaving the system in an unknown state.
This becomes especially important with Azure Blob Storage because many storage operations involve network calls and can take considerably longer than normal application requests.
This article explains how to design Azure Storage jobs so they can be cancelled or stopped safely without treating every interruption as a complete failure.
What Does “Stop Cleanly” Mean?
Stopping a storage job cleanly does not simply mean killing the process.
Suppose an application is processing 50,000 blobs. It has already completed 31,000 blobs and is currently processing blob 31,001 when the application receives a shutdown request.
A poor implementation might terminate immediately.
The result could be:
Work that was already completed may be repeated.
A blob operation may be interrupted.
The application may not record its progress.
Retry logic may restart too much work.
Temporary files may remain.
A copy operation may still be running.
Operators may not know where the job stopped.
A graceful implementation treats stopping as part of the normal lifecycle.
A typical lifecycle looks like this:
Running
|
| Stop requested
v
Stop accepting new work
|
v
Cancel or finish current operations
|
v
Save progress
|
v
Release resources
|
v
ExitThe exact behavior depends on the type of storage operation, but the principle remains the same.
Why Large Storage Jobs Need Special Handling
Azure Storage operations are remote operations. The application sends a request to Azure Storage, waits for a response, and may continue processing the returned data.
For a small operation, interruption may not matter much.
For a long-running job, interruption creates several questions.
Was the operation completed?
Was the response received?
Did the server complete the operation before the client disconnected?
Should the application retry it?
Was the data already copied?
Was the destination partially updated?
These questions are important because a network timeout does not always mean the server did nothing.
For example, an application might send a blob operation and then lose its network connection before receiving the response. Retrying without understanding the operation can result in duplicate work or unnecessary processing.
This is why cancellation, retries, and idempotency need to be designed together.
Cancellation Tokens Are the First Building Block
In .NET applications, CancellationToken provides a standard way to tell asynchronous work that it should stop.
The token does not forcibly terminate a running method. Instead, application code and supporting APIs observe the token and stop when cancellation is appropriate.
A simple worker can look like this:
public async Task ProcessBlobsAsync(
BlobContainerClient container,
CancellationToken cancellationToken)
{
await foreach (var blob in container.GetBlobsAsync(
cancellationToken: cancellationToken))
{
cancellationToken.ThrowIfCancellationRequested();
await ProcessBlobAsync(
container,
blob.Name,
cancellationToken);
}
}The important part is that cancellation is passed through the entire call chain.
For example:
Worker
|
+-- ProcessBlobsAsync
|
+-- ProcessBlobAsync
|
+-- DownloadAsync
|
+-- UploadAsyncIf the top-level method receives a cancellation token but the lower-level methods ignore it, the application may still take a long time to stop.
Passing Cancellation Through Storage Operations
A common mistake is to implement cancellation only around the outer loop.
For example, this code checks cancellation before starting an operation:
cancellationToken.ThrowIfCancellationRequested();
await blobClient.DownloadToAsync(filePath);But the download itself does not receive the token.
If the download takes several minutes, cancellation may not be observed until the download finishes.
A better approach is to pass the token into the Azure SDK operation where the API supports cancellation:
await blobClient.DownloadToAsync(
filePath,
cancellationToken);The same principle applies to uploads and other asynchronous operations that expose cancellation support.
The goal is not to make every operation instantly disappear. The goal is to give the application and SDK a way to stop waiting for work when cancellation is requested.
Graceful Shutdown in a Background Worker
A long-running storage process is often implemented as a .NET BackgroundService.
The worker should treat cancellation as an expected lifecycle event rather than an unexpected exception.
A simplified implementation looks like this:
public class StorageWorker : BackgroundService
{
private readonly BlobContainerClient _container;
public StorageWorker(BlobContainerClient container)
{
_container = container;
}
protected override async Task ExecuteAsync(
CancellationToken stoppingToken)
{
try
{
await ProcessBlobsAsync(
_container,
stoppingToken);
}
catch (OperationCanceledException)
when (stoppingToken.IsCancellationRequested)
{
// Expected during graceful shutdown.
}
}
private async Task ProcessBlobsAsync(
BlobContainerClient container,
CancellationToken cancellationToken)
{
await foreach (var blob in container.GetBlobsAsync(
cancellationToken: cancellationToken))
{
cancellationToken.ThrowIfCancellationRequested();
await ProcessBlobAsync(
container,
blob.Name,
cancellationToken);
}
}
private async Task ProcessBlobAsync(
BlobContainerClient container,
string blobName,
CancellationToken cancellationToken)
{
await Task.Delay(100, cancellationToken);
// Application-specific processing.
}
}The important detail is the distinction between cancellation and failure.
If the service is shutting down and the cancellation token is cancelled, an OperationCanceledException can be expected.
If a storage request fails because authentication is invalid or the storage account is unavailable, that is a different operational condition and should be handled separately.
Do Not Treat Cancellation as a Storage Error
This distinction becomes important in production monitoring.
Consider a worker that is restarted during a deployment.
The worker receives a shutdown signal, cancels its current operation, and exits.
If the monitoring system records this as a storage failure, operators may investigate a problem that never existed.
A useful classification is:
Expected cancellation
|
+-- Deployment shutdown
+-- User requested stop
+-- Application shutdown
Operational failure
|
+-- Authentication failure
+-- Permission failure
+-- Network failure
+-- Storage service error
Application failure
|
+-- Invalid input
+-- Processing exception
+-- Data validation failureThese categories should produce different logs, metrics, and alerts.
Use Timeouts for Operations That Should Not Run Forever
Cancellation and timeout solve related but different problems.
Cancellation usually means another part of the application has decided that the work should stop.
A timeout means the operation has taken longer than an acceptable limit.
For example, a blob download might normally complete within a few seconds. If the application waits indefinitely because of a network problem, the worker can become stuck.
A timeout can provide a maximum waiting period.
For application-level operations, a linked cancellation token can combine both conditions:
using var timeoutCts =
CancellationTokenSource.CreateLinkedTokenSource(
stoppingToken);
timeoutCts.CancelAfter(TimeSpan.FromMinutes(5));
await blobClient.DownloadToAsync(
filePath,
timeoutCts.Token);This creates two cancellation paths:
Application shutdown
|
v
stoppingToken
|
+------+
|
v
Linked token
^
|
5-minute timeoutThe operation stops when either condition occurs.
The timeout should be based on the actual workload. A five-minute limit might be reasonable for one application and completely wrong for another that regularly transfers large objects.
Do Not Use Very Short Timeouts for Large Files
A common mistake is applying one timeout to every storage operation.
Suppose an application transfers files ranging from 10 MB to 500 GB.
A timeout that works for a 10 MB object may be completely unsuitable for a 500 GB object.
Instead, consider:
File size
Expected network throughput
Storage operation type
Application SLA
Retry policy
Maximum acceptable processing time
The timeout should represent the application's operational expectation rather than an arbitrary number.
Stop Starting New Work First
When a large batch job receives a stop request, the safest first step is usually to stop scheduling new work.
Consider a worker processing batches:
Batch 1 -> running
Batch 2 -> running
Batch 3 -> queued
Batch 4 -> queued
Batch 5 -> queuedWhen cancellation arrives, the application should avoid starting Batch 4 and Batch 5.
This sounds simple, but concurrent workers can make it more complicated.
For example, if 20 tasks are consuming work from a queue, cancellation may arrive while several tasks are between fetching the next item and starting the storage operation.
The worker should check cancellation at those boundaries.
while (!cancellationToken.IsCancellationRequested)
{
var item = await GetNextItemAsync(cancellationToken);
if (item == null)
break;
await ProcessItemAsync(
item,
cancellationToken);
}This prevents the application from continuing to create unnecessary work after shutdown begins.
Limit Concurrency During Large Storage Jobs
Large storage jobs often use parallel processing to improve throughput.
For example:
1 worker -> slow
8 workers -> faster
50 workers -> potentially faster
500 workers -> potentially unstableMore concurrency does not automatically mean better performance.
Too much parallelism can increase:
Network pressure
Memory consumption
CPU usage
Storage throttling
Retry volume
Application shutdown time
A controlled concurrency limit is usually easier to operate.
For example:
var options = new ParallelOptions
{
MaxDegreeOfParallelism = 8,
CancellationToken = cancellationToken
};
await Parallel.ForEachAsync(
blobs,
options,
async (blob, token) =>
{
await ProcessBlobAsync(
container,
blob.Name,
token);
});When cancellation occurs, the parallel operation can stop scheduling additional work while existing operations respond to the cancellation token.
Make Processing Idempotent
Idempotency is one of the most important concepts for resumable storage jobs.
An operation is idempotent when repeating the same operation produces the same intended result rather than creating an additional unwanted effect.
For example, suppose a job reads a blob and generates a processed output.

Join the conversation! Your thoughts help the community grow.