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.