The third part of the series about advanced patterns in Cosmos DB. After partition keys and Change Feed, we move to the global level: writes in multiple regions.
Single-region writes with read replicas — the starting point
The first important nuance: you don't need multi-region writes to serve global reads. The classic configuration — one write region + read replicas in other regions — covers most scenarios: users read from the nearby region, all writes go to the main region.
# Add a read region
az cosmosdb update \
--name my-cosmos --resource-group my-rg \
--locations regionName=westeurope failoverPriority=0 isZoneRedundant=true \
--locations regionName=eastus failoverPriority=1 isZoneRedundant=true
Multi-region writes come into discussion only when:
- Write latency matters globally — active users on multiple continents who write frequently (a chat, a collaborative editor), and the round-trip to the single write region is unacceptable
- Write availability everywhere — you want a failed region not to stop writes anywhere (near-zero RTO, automatic failover)
The price: conflict complexity. If your audience is regionally concentrated (e.g., customers from Romania and the EU), a single write region in West Europe is simpler and sufficient — a conclusion that applies directly to a regional SaaS.
Enabling multi-region writes
az cosmosdb update \
--name my-cosmos --resource-group my-rg \
--enable-multiple-write-locations true
// .NET SDK: tell the client which regions it prefers, in order
builder.Services.AddSingleton(_ => new CosmosClient(
accountEndpoint: endpoint,
tokenCredential: new DefaultAzureCredential(),
clientOptions: new CosmosClientOptions
{
// The instance from Europe writes/reads in West Europe,
// the one from US in East US -- each local
ApplicationPreferredRegions = new List<string>
{
"West Europe",
"East US"
}
}));
With ApplicationPreferredRegions, each app instance (deployed regionally) writes in its own region — local write latency. The SDK automatically fails over through the preference list if the first region fails.
Consistency levels in a multi-region context
With writes in multiple places, the consistency level becomes a visible decision:
| Level | Guarantee | Multi-region note |
|---|---|---|
| Strong | Global linearizability | Incompatible with multi-region writes on accounts with distant regions — latency would be global RTT |
| Bounded Staleness | Maximum delay of K versions / T seconds | Good compromise when you want predictability |
| Session | Consistency within own session | The default and the correct choice for most applications — you read your own writes |
| Consistent Prefix | Write order preserved | Rare explicit choice |
| Eventual | Convergence, no order | Only for data where order doesn't matter |
Session consistency is worth emphasizing: a user who writes a message sees it immediately in their own session, even if global replication takes time. For a chat or a per-client dashboard, that's exactly what you want.
Conflicts: inevitable, so managed
Two regions write the same document almost simultaneously. Replication brings them face to face: conflict. Cosmos DB offers two resolution policies, set per container, at creation:
Last-Writer-Wins (default)
The document with the higher value on a numeric property wins (default _ts, the timestamp). Simple and sufficient when losing a concurrent write is acceptable:
var containerProperties = new ContainerProperties("sessions", "/tenantId")
{
ConflictResolutionPolicy = new ConflictResolutionPolicy
{
Mode = ConflictResolutionMode.LastWriterWins,
// You can use your own property instead of _ts
ResolutionPath = "/updatedEpoch"
}
};
await database.CreateContainerIfNotExistsAsync(containerProperties);
Custom with conflict feed
When silent loss is not acceptable (e.g., two concurrent updates on a tenant's configuration that must be combined, not overwritten), choose Custom: unresolved conflicts go into a conflict feed that you process:
// Custom policy without stored procedure -- conflicts go to feed
var props = new ContainerProperties("tenant-config", "/tenantId")
{
ConflictResolutionPolicy = new ConflictResolutionPolicy
{
Mode = ConflictResolutionMode.Custom
}
};
// Conflict processing -- periodic job or BackgroundService
var conflicts = _container.Conflicts;
using var iterator = conflicts.GetConflictQueryIterator<ConflictProperties>();
while (iterator.HasMoreResults)
{
foreach (var conflict in await iterator.ReadNextAsync())
{
var current = conflicts.ReadCurrentConflictContent<TenantConfig>(conflict);
// Your merge logic: combine, choose, or raise alert
var merged = MergeConfigs(current /*, ... */);
await _container.UpsertItemAsync(merged, new PartitionKey(merged.TenantId));
await conflicts.DeleteAsync(conflict, new PartitionKey(merged.TenantId));
}
}
A design rule more valuable than any policy: model your data to avoid conflicts. Per-user or per-session documents (not shared), append-only operations (new messages, no updates on the same document) make conflicts almost impossible by design.
What’s next
Conflicts between regions have a smaller and much more frequent sibling: concurrent writes on the same document in the same region. In the fourth part: optimistic concurrency with ETags — the pattern that prevents lost updates.
Questions? Write me at contact@ludoprogramming.com.