RO EN

Cosmos DB patterns (2) — Change Feed as a trigger for events

Cosmos DB patterns (2) — Change Feed as a trigger for events ✨ Imagine generată cu AI
Doru Bulubașa
28 July 2026
45 views

The second part of the series about advanced patterns in Cosmos DB. With the partition key established, we move on to one of the most powerful features: Change Feed.


What is Change Feed

Change Feed is the ordered stream of changes in a container: every insert and every update appears in the feed, in the order in which it happened (per partition key). Basically, your container becomes a free event source — without writing publishing code, without dual-write.

What the feed does NOT contain: deletions (use soft delete with TTL if you need to react to them) and intermediate versions (if a document is modified 3 times quickly, you can only see the final state).

Why it matters for architecture: many scenarios that otherwise require explicit messages are elegantly solved by reading the feed:

  • Indexing — the saved document must be indexed for search (embeddings, full-text). The feed is the natural trigger.
  • Cache invalidation — a tenant's configuration has changed, the cache must be invalidated on all instances.
  • Projections / materialized views — you maintain a denormalized read container, updated from the write container's feed.
  • Archiving and analytics — you copy changes to cheap storage or to an analytics system.

Change Feed Processor in .NET

The SDK offers Change Feed Processor — automatically manages checkpointing (where you left off), partition distribution among instances, and resuming after restart. It requires a lease container: a small container where it keeps its state.

public class MessageIndexerService : BackgroundService
{
    private readonly CosmosClient _client;
    private readonly ILogger<MessageIndexerService> _logger;
    private ChangeFeedProcessor? _processor;

    public MessageIndexerService(CosmosClient client, ILogger<MessageIndexerService> logger)
    {
        _client = client;
        _logger = logger;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        var database = _client.GetDatabase("ChatDb");
        var monitored = database.GetContainer("messages");   // the monitored container
        var leases = database.GetContainer("leases");        // processor state

        _processor = monitored
            .GetChangeFeedProcessorBuilder<ChatMessage>(
                processorName: "message-indexer",   // stable identity!
                onChangesDelegate: HandleChangesAsync)
            .WithInstanceName(Environment.MachineName) // unique per instance
            .WithLeaseContainer(leases)
            .WithStartTime(DateTime.MinValue.ToUniversalTime()) // from the beginning
            .Build();

        await _processor.StartAsync();

        stoppingToken.Register(() =>
            _processor.StopAsync().GetAwaiter().GetResult());
    }

    private async Task HandleChangesAsync(
        ChangeFeedProcessorContext context,
        IReadOnlyCollection<ChatMessage> changes,
        CancellationToken ct)
    {
        foreach (var message in changes)
        {
            await _searchIndexer.IndexAsync(message, ct);
        }

        _logger.LogInformation(
            "Processed {Count} changes, RU: {RU}",
            changes.Count, context.Headers.RequestCharge);
    }
}

Important details:

  • Stable processorName — logically identifies this consumer; if you change it = the feed is resumed from StartTime. Two processors with different names on the same container each receive all changes (like two subscriptions).
  • Unique WithInstanceName — lease distribution among instances is done on this.
  • Lease container with partition key /id — the standard convention; create it once with low throughput (400 RU or shared).

Scaling across multiple instances

Run 3 instances of the same BackgroundService (same processorName, different instanceName)? The processor automatically divides partitions among them via leases. One instance dies? Its leases expire and are taken over by the others. Zero extra code.

Correlation with the Container Apps series: the Change Feed consumer is exactly the type of workload that naturally runs in ACA — but beware of scale-to-zero: with 0 replicas, no one reads the feed. For continuous processing, min-replicas 1; for periodic processing tolerant to delay, scale-to-zero is acceptable (the feed waits for you).


Error handling: the feed has no DLQ

Crucial difference from Service Bus: if the delegate throws an exception, the batch is re-delivered entirely, endlessly. A poisoned document blocks its partition. There is no automatic dead-letter — you build it yourself:

private async Task HandleChangesAsync(
    ChangeFeedProcessorContext context,
    IReadOnlyCollection<ChatMessage> changes,
    CancellationToken ct)
{
    foreach (var message in changes)
    {
        try
        {
            await _searchIndexer.IndexAsync(message, ct);
        }
        catch (Exception ex)
        {
            // DO NOT re-throw -- it would block the partition on this batch
            _logger.LogError(ex, "Indexing failed for {Id}", message.Id);

            // Manual dead-letter: save the failure for reprocessing
            await _failedItemsContainer.CreateItemAsync(new FailedChange
            {
                SourceId = message.Id,
                PartitionKey = message.SessionId,
                Error = ex.Message,
                Payload = JsonConvert.SerializeObject(message),
                FailedAt = DateTime.UtcNow
            }, cancellationToken: ct);
        }
    }
}

Rule: exceptions must not leave the delegate. Individual failures go into a failed items container (with alerting on it, like the DLQ in Service Bus), and the batch advances.


Change Feed vs. explicit messages

Criterion Change Feed Service Bus + Outbox
Publishing effort Zero — automatic on write Outbox pattern required
What it carries The document state Explicitly modeled business event
Deletions No (only with soft delete) Yes, as event
External consumers to the system Unsuitable (direct DB access) Natural
Retry / DLQ Manual Built-in

Practical rule: Change Feed for internal reactions to data changes (indexing, cache, projections); Service Bus for business events consumed by other services.


What’s next

In the third part: multi-region writes — when you need writes in multiple regions, what happens on conflicts and how to configure conflict resolution policies.

Questions? Write me at contact@ludoprogramming.com.