Posted in:

I recently had to deal with a situation where there was potential for multiple processes to attempt to modify the same Azure blob at the same time.

By default, if two processes read the same Azure blob and then both try to write updated content back, one of them will silently overwrite the other's changes. Fortunately, Azure Blob Storage provides a built-in mechanism to prevent this, called ETags. An ETag is simply a version token that changes every time a blob is modified. By passing the ETag you read back as a condition on your write, you can tell Azure to "only accept this update if nobody else has changed the blob since I last read it." If someone else got there first, Azure returns a 412 Precondition Failed and you can retry with fresh data.

Let's take a look at how to implement an optimistic concurrency pattern using ETags in C#.

Setting Up the Clients

First, let's get connected to the storage account using the convenient DefaultAzureCredential to avoid hard-coding any keys.

var serviceUri = new Uri("https://youraccountname.blob.core.windows.net/");
var credential = new DefaultAzureCredential();
var blobServiceClient = new BlobServiceClient(serviceUri, credential);
var containerClient = blobServiceClient.GetBlobContainerClient("your-container");

You'll need to reference the Azure.Identity and Azure.Storage.Blobs NuGet packages.

Fetching the Blob and Its ETag

The crucial step is to retrieve the ETag alongside the blob content. Here I've made a simple helper called DownloadContentAsync that returns both in one call:

async Task<(string, ETag)> FetchContentsAsync(BlobClient blobClient)
{
    try
    {
        var content = await blobClient.DownloadContentAsync();
        return (content.Value.Content.ToString(), content.Value.Details.ETag);
    }
    catch (RequestFailedException ex) when (ex.Status == 404)
    {
        // Blob doesn't exist yet; return empty content and a default (empty) ETag
        return (string.Empty, default);
    }
}

Writing Back with an ETag Condition

Now that we've retrieved the existing blob contents, let's imagine that we've updated them and now we want to re-upload.

Before uploading, we need set a BlobRequestConditions on the upload options. There are two cases:

  • Blob didn't exist (etag == default): use IfNoneMatch = new ETag("*") so the upload only succeeds if the blob still doesn't exist.
  • Blob already exists: use IfMatch = etag so the upload only succeeds if the blob's current ETag still matches the one we read.

If the condition fails, Azure returns 412 Precondition Failed and the SDK throws a RequestFailedException. We catch that and return false to signal a conflict.

Again I've created a simple helper method UpdateContentsAsync to show how we can do this and detect the concurrency issue.

async Task<bool> UpdateContentsAsync(BlobClient blobClient, string contents, ETag etag)
{
    var uploadOptions = new BlobUploadOptions
    {
        Conditions = etag == default
            // Blob didn't exist: only create if still absent
            ? new BlobRequestConditions { IfNoneMatch = new ETag("*") }
            // Blob existed: only overwrite if ETag still matches
            : new BlobRequestConditions { IfMatch = etag }
    };

    try
    {
        await blobClient.UploadAsync(BinaryData.FromString(contents), uploadOptions);
        return true;
    }
    catch (RequestFailedException ex) when (ex.Status == 412 || ex.ErrorCode == BlobErrorCode.ConditionNotMet)
    {
        // Another writer changed the blob between our read and write
        return false;
    }
}

Retrying with Exponential Backoff

A conflict just means someone else updated the blob first, so we don't need to give up. Instead we can just fetch the latest version and try again. To avoid a situation of too many processes all trying to update at the same time, we can back off exponentially and add a small random jitter to each delay.

async Task ModifyBlob(
    BlobContainerClient container,
    string blobName,
    Func<string, Task<string>> transform,
    CancellationToken ct)
{
    ArgumentNullException.ThrowIfNull(transform);

    var maxRetries = 5;
    var attempt = 0;
    var delay = TimeSpan.FromSeconds(2);
    var blobClient = container.GetBlobClient(blobName);

    while (attempt < maxRetries)
    {
        ct.ThrowIfCancellationRequested();
        attempt++;

        var (contents, etag) = await FetchContentsAsync(blobClient);
        var newContents = await transform(contents);

        if (await UpdateContentsAsync(blobClient, newContents, etag))
            return; // success

        // Back off before retrying
        var jitterMs = Random.Shared.Next(0, 100);
        await Task.Delay(delay + TimeSpan.FromMilliseconds(jitterMs), ct);

        // Exponential backoff, capped at 5 seconds
        delay = TimeSpan.FromMilliseconds(Math.Min(delay.TotalMilliseconds * 2, 5_000));
    }

    throw new InvalidOperationException(
        $"Failed to update blob '{blobName}' after {maxRetries} attempts.");
}

The transform delegate receives the current blob content and returns the new content. ModifyBlob handles all the retry logic so callers don't need to think about ETags at all.

Seeing Concurrency Conflicts in Action

To check this actually works we can make a simple modification to simulate two concurrent updaters. The outer transform, before writing its own change, triggers an inner call to modify the blob that successfully commits first. When control returns to the outer call its ETag is now stale, so the first attempt fails and the retry loop kicks in.

bool firstTime = true;

await ModifyBlobTest(containerClient, blobName, async currentContent =>
{
    if (firstTime)
    {
        // While the outer call holds its ETag, the inner call commits a change,
        // invalidating the outer ETag.
        await ModifyBlobTest(
            containerClient, blobName,
            c => Task.FromResult($"{c}\r\nInner update {DateTimeOffset.Now}"),
            CancellationToken.None);
    }
    firstTime = false;
    return $"{currentContent}\r\nOuter conflicting update {DateTimeOffset.Now}";
}, CancellationToken.None);

On the first pass through the outer loop you'll see output like:

Successful update of etag "0x1234..."   ← inner update wins
Concurrency conflict detected. Old ETag: "0x1234..."  ← outer detects stale ETag
Successful update of etag "0x5678..."   ← outer retries and succeeds

Both updates end up in the blob — neither is lost.

Summary

ETags give you a simple optimistic concurrency mechanism for Azure Blob Storage: read the blob and its ETag together, apply your changes, then write back with a condition that fails if the blob has been modified in the meantime. If you wrap that in a retry loop with exponential backoff and jitter, you have a robust pattern that handles any number of concurrent writers without data loss or locks.

Obviously in an ideal world you wouldn't be making lots of concurrent updates to blobs, but if you do, you can use the approach shown in the ModifyBlob helper shown above.