RO EN

Cosmos DB patterns (4) — optimistic concurrency with ETags

Cosmos DB patterns (4) — optimistic concurrency with ETags ✨ Imagine generată cu AI
Doru Bulubașa
30 July 2026
33 views

The fourth part of the series about advanced patterns in Cosmos DB. The previous part dealt with conflicts between regions. Now we go down to everyday conflicts: two processes modifying the same document.


Anatomy of a lost update

Concrete scenario: the message counter of a chat session, with a limit per session. Two requests arrive simultaneously:

Process A: reads the session (messageCount = 5)
Process B: reads the session (messageCount = 5)
Process A: sets 6, saves  --> OK
Process B: sets 6, saves  --> OK, but it ERASED A's write

Reality: 7 messages. In the database: 6. The limit is bypassed.

Both writes succeeded, none failed, but one disappeared. This is a lost update — and the more instances you have (scaling from the Container Apps series!), the more frequent it is.


ETag: the default version of each document

Each Cosmos DB document has the system property _etag — it changes automatically with every write. Optimistic concurrency means: when saving, you declare which version you read; if it changed in the meantime, the write is rejected.

public class ChatSession
{
    public string Id { get; set; } = default!;
    public string TenantId { get; set; } = default!;
    public int MessageCount { get; set; }

    [JsonProperty("_etag")]
    public string ETag { get; set; } = default!;   // automatically populated on read
}
public async Task IncrementMessageCountAsync(string sessionId, string tenantId)
{
    var response = await _container.ReadItemAsync<ChatSession>(
        sessionId, new PartitionKey(tenantId));
    var session = response.Resource;

    session.MessageCount++;

    // Write succeeds ONLY if the document has not changed in the meantime
    await _container.ReplaceItemAsync(
        session,
        session.Id,
        new PartitionKey(tenantId),
        new ItemRequestOptions
        {
            IfMatchEtag = session.ETag
        });
}

If another process modified the document between your read and write, you get a CosmosException with status 412 PreconditionFailed. Nothing is lost — you just need to retry.


The correct retry: re-read, re-apply, re-try

The classic mistake on 412: retrying the same write with the same old ETag — it will fail identically forever. The correct retry repeats the entire cycle: fresh read, re-application of the change on the new state, write with the new ETag:

public async Task<bool> TryIncrementWithLimitAsync(
    string sessionId, string tenantId, int maxMessages,
    int maxAttempts = 3)
{
    for (var attempt = 1; attempt <= maxAttempts; attempt++)
    {
        // 1. FRESH read on each attempt
        var response = await _container.ReadItemAsync<ChatSession>(
            sessionId, new PartitionKey(tenantId));
        var session = response.Resource;

        // 2. Business logic on the current state
        if (session.MessageCount >= maxMessages)
            return false;   // limit reached -- correct even under concurrency

        session.MessageCount++;

        try
        {
            // 3. Conditional write
            await _container.ReplaceItemAsync(
                session, session.Id, new PartitionKey(tenantId),
                new ItemRequestOptions { IfMatchEtag = session.ETag });
            return true;
        }
        catch (CosmosException ex)
            when (ex.StatusCode == HttpStatusCode.PreconditionFailed)
        {
            // Another process modified -- restart the full cycle
            _logger.LogDebug(
                "ETag conflict on session {Id}, attempt {N}",
                sessionId, attempt);
        }
    }

    throw new ConcurrencyException(
        $"Session {sessionId}: too many concurrent conflicts");
}

Note that the limit check is inside the loop: under concurrency, the decision is always made on fresh state. This is exactly what the pattern suitable for per-session limits, usage quotas, and any read-modify-write invariant does.


Alternative for point operations: Patch

When the change is a simple operation on a field (increment, set), Partial Document Update completely avoids the read-modify-write cycle — the server applies the operation atomically:

// Atomic increment on the server -- no read, no ETag, no retry
await _container.PatchItemAsync<ChatSession>(
    sessionId,
    new PartitionKey(tenantId),
    new[]
    {
        PatchOperation.Increment("/MessageCount", 1)
    });

Two nuances:

  • Path casing — a costly lesson: /MessageCount must match the property in the stored document exactly. Wrong casing = error, not a no-op.
  • Patch does not see business logic — the increment is atomic but does not check the limit. For "increment only if under limit," you can add a filter predicate condition to the patch, or stick with the ETag cycle.
// Conditional patch -- increment only if under limit
await _container.PatchItemAsync<ChatSession>(
    sessionId, new PartitionKey(tenantId),
    new[] { PatchOperation.Increment("/MessageCount", 1) },
    new PatchItemRequestOptions
    {
        FilterPredicate = "FROM c WHERE c.MessageCount < 100"
    });
// 412 if the predicate is not met

How to choose

Situation Choice
Simple increment / set on a field Patch
Increment with simple condition Patch with FilterPredicate
Business logic on multiple fields ETag + retry
Update entire document from UI (form) ETag — detect concurrent edits

What’s next

All patterns in the series deserve automated tests — but not on the production account. In the final part: Cosmos DB Emulator in CI/CD, with GitHub Actions, real NUnit tests, and certificate and timing pitfalls.

Questions? Write to me at contact@ludoprogramming.com.