How to Design a Multi-Region Database System — A Senior+ Guide
Article #178 — Architecting globally distributed databases for low latency, high availability, and regulatory compliance
1. Introduction: Why Multi-Region
The modern internet serves users on every continent. A database confined to a single data center in Virginia introduces 200+ milliseconds of latency for users in Tokyo, São Paulo, or Frankfurt. Beyond latency, single-region systems present a single point of failure — a regional outage, whether caused by natural disaster, power grid failure, or a misconfigured network partition, can take down your entire product. Multi-region database design addresses both problems simultaneously: it places data closer to users for sub-50ms response times and distributes risk across geographic boundaries so that no single event can erase availability entirely.
This article is written for senior and staff engineers who already understand relational databases, basic replication, and CAP theorem at a conceptual level. We will go deeper — into consensus protocols, conflict resolution strategies, distributed transaction patterns, schema evolution across regions, compliance with data residency laws, and the cost trade-offs that arise when you operate at global scale. Every section includes concrete code examples in C#, architectural diagrams in Mermaid, and reference tables for quick comparison.
Consider a SaaS platform serving enterprise customers. An American customer expects queries against their data to complete in under 100ms regardless of where they travel. A European customer requires that their personal data never leave EU borders due to GDPR. An Asian customer demands availability even when a typhoon knocks out a Tokyo data center. These are not hypothetical scenarios — they are everyday constraints for products at scale. Designing for multi-region operation from the start is dramatically cheaper than retrofitting it later, and this guide gives you the mental models and concrete patterns to do it correctly.
We will use C# throughout the code examples because it is a mature, strongly-typed language commonly used in enterprise distributed systems. The patterns, however, are language-agnostic — the same decisions apply whether you are writing Go, Java, or Rust. The key is understanding the trade-off space: consistency versus latency, availability versus cost, simplicity versus flexibility. By the end of this article, you will be able to articulate these trade-offs clearly in a system design interview and, more importantly, make sound architectural decisions in production.
Let us begin by establishing the theoretical foundation that governs every multi-region design choice: the CAP theorem and its practical extension, the PACELC framework.
2. CAP Theorem and PACELC in Practice
The CAP theorem, formulated by Eric Brewer in 2000 and formally proven by Gilbert and Lynch, states that a distributed data store can provide at most two of three guarantees: Consistency (every read receives the most recent write), Availability (every request receives a non-error response), and Partition tolerance (the system continues operating despite network partitions). In a multi-region system, network partitions are a physical reality — cables get cut, routers fail, and cross-continent links experience unpredictable latency spikes. Because partitions are inevitable, you must choose between consistency and availability during a partition. This is not a one-time decision; it is a spectrum, and different parts of your system may make different choices.
The PACELC framework extends CAP by addressing what happens when the system is running normally, without a partition. If there is a Partition, you choose between Availability and Consistency (the A/C in PAC). Else, when the system is running smoothly, you choose between Latency and Consistency (the L/C in EL). This captures the real-world trade-off: even when the network is healthy, you must decide whether to route reads to a local replica (low latency, possible staleness) or to the leader (strong consistency, higher latency). Most production systems operate in the "else" state 99.9%+ of the time, so the latency-consistency trade-off in normal operation is often more impactful than the partition behavior.
| System | During Partition | During Normal Operation | Category |
|---|---|---|---|
| Google Spanner | Consistency | Consistency (with higher latency) | CP / EC |
| Amazon DynamoDB | Availability | Latency-optimized (tunable consistency) | AP / EL |
| CockroachDB | Consistency | Consistency (with follower reads for lower latency) | CP / EC |
| Cassandra | Availability | Latency-optimized (tunable via consistency level) | AP / EL |
| Azure Cosmos DB | Tunable (session/strong/bounded) | Tunable per-request | Tunable |
| Amazon Aurora Global | Consistency (with potential read-only) | Consistency with cross-region lag | CP / EC |
For most multi-region systems, the practical decision is not "CP or AP" but rather "what consistency level do I need for each access pattern?" A user's account balance might require strong consistency (read from the leader), while their activity feed can tolerate a few seconds of staleness (read from a local follower). Designing your system with tunable consistency — the ability to choose consistency level per query — is the hallmark of a mature multi-region architecture. We will see how this works in practice throughout the rest of this article.
It is also important to understand that consistency in a multi-region system is not binary. There are several intermediate points on the spectrum: linearizability (the strongest, equivalent to a single-machine model), sequential consistency (all operations appear in some total order consistent with program order), causal consistency (causally related operations are seen in order), and eventual consistency (all replicas converge to the same value eventually, but no timing guarantee). Each level offers different trade-offs between performance and correctness. Choosing the right level for each data path is one of the most important architectural decisions in a multi-region system.
In the sections that follow, we will explore how specific databases and patterns navigate these trade-offs, and how you as an architect can make deliberate, well-reasoned choices rather than defaulting to the extremes.
3. System Architecture Overview
A typical multi-region database architecture consists of multiple regions, each containing one or more database nodes, connected by replication channels. Application servers in each region connect to their local database for reads and writes. A global routing layer directs client requests to the nearest healthy region. Background processes handle cross-region replication, schema synchronization, and conflict resolution. Understanding the full picture before diving into individual components helps you see how each decision cascades through the system.
(Anycast / GeoDNS)"] GSLB["Global Service Discovery"] end subgraph "Region: US-East" AppUS["App Servers"] DBPrimaryUS["DB Primary
(Leader)"] DBReplicaUS["DB Replica
(Follower)"] end subgraph "Region: EU-West" AppEU["App Servers"] DBPrimaryEU["DB Primary
(Leader)"] DBReplicaEU["DB Replica
(Follower)"] end subgraph "Region: AP-South" AppAP["App Servers"] DBPrimaryAP["DB Primary
(Leader)"] DBReplicaAP["DB Replica
(Follower)"] end LB --> AppUS LB --> AppEU LB --> AppAP AppUS --> DBPrimaryUS AppUS --> DBReplicaUS AppEU --> DBPrimaryEU AppEU --> DBReplicaEU AppAP --> DBPrimaryAP AppAP --> DBReplicaAP DBPrimaryUS <-->|"Async/Sync Replication"| DBPrimaryEU DBPrimaryEU <-->|"Async/Sync Replication"| DBPrimaryAP DBPrimaryUS <-->|"Async/Sync Replication"| DBPrimaryAP DBPrimaryUS --> DBReplicaUS DBPrimaryEU --> DBReplicaEU DBPrimaryAP --> DBReplicaAP
The diagram above shows the canonical three-region deployment. Each region has its own primary (leader) node that accepts writes, and one or more follower replicas that serve read traffic. Cross-region replication runs between the primary nodes. The global load balancer directs incoming client requests to the nearest healthy region using latency-based routing or GeoDNS. Within a region, application servers connect to the local primary for writes and can use either the primary or local follower for reads, depending on the required consistency level.
C# code demonstrating the region-aware connection logic:
C#
public class RegionAwareConnectionFactory
{
private readonly Dictionary<string, RegionConfig> _regions;
private readonly IHealthChecker _healthChecker;
private readonly ILatencyMeasurer _latencyMeasurer;
public RegionAwareConnectionFactory(
Dictionary<string, RegionConfig> regions,
IHealthChecker healthChecker,
ILatencyMeasurer latencyMeasurer)
{
_regions = regions;
_healthChecker = healthChecker;
_latencyMeasurer = latencyMeasurer;
}
public async Task<IDbConnection> GetConnectionAsync(
ConsistencyRequirement consistency)
{
var healthyRegions = new List<RegionConfig>();
foreach (var region in _regions.Values)
{
if (await _healthChecker.IsHealthyAsync(region.Endpoint))
healthyRegions.Add(region);
}
if (healthyRegions.Count == 0)
throw new AllRegionsUnavailableException();
if (consistency == ConsistencyRequirement.Strong)
{
// For strong consistency, route to the designated leader
var leader = healthyRegions.First(r => r.IsLeader);
return leader.CreateConnection();
}
// For eventual consistency, pick the lowest-latency healthy region
var best = healthyRegions
.OrderBy(r => _latencyMeasurer.Measure(r.Endpoint))
.First();
return best.CreateConnection();
}
}
public enum ConsistencyRequirement
{
Strong,
Eventual,
BoundedStaleness
}
public class RegionConfig
{
public string Name { get; set; }
public string Endpoint { get; set; }
public bool IsLeader { get; set; }
public IDbConnection CreateConnection() => new SqlConnection(Endpoint);
}
This pattern ensures that every database call is routed to the optimal region based on both health and consistency requirements. In production, you would cache health check results and latency measurements to avoid adding overhead to every request. The health checker itself typically runs a lightweight ping query (e.g., SELECT 1) every few seconds against each region's database endpoint.
The architecture also requires careful attention to how data is partitioned across regions (covered in Section 4), how changes are replicated (Section 5), and how conflicts are resolved (Section 7). Each of these topics builds on the foundation established here. The key insight is that multi-region design is not about duplicating a single-region architecture — it requires a fundamentally different approach to data placement, consistency, and failure handling.
4. Data Partitioning Strategies
Data partitioning determines which data lives in which region. This is one of the most consequential decisions in multi-region design because it affects latency, consistency, compliance, and cost. There are three primary strategies: geo-based partitioning, tenant-based partitioning, and hybrid approaches. Each has distinct trade-offs, and the right choice depends on your workload characteristics, compliance requirements, and user distribution.
Geo-Based Partitioning
In geo-based partitioning, data is placed in the region closest to where it was created or where it is most frequently accessed. User profiles for European users live in EU-West, Asian user data lives in AP-South, and so on. This minimizes read latency for the majority of access patterns because most reads are served from the local region. Write latency is also minimized since writes go to the local primary. The downside is cross-region queries — if an administrator in the US needs to search across all user data, the query must fan out to all regions and aggregate results, which is slow and complex.
Tenant-Based Partitioning
In SaaS applications with multi-tenant architectures, tenant-based partitioning assigns entire tenants (customers) to specific regions. Enterprise tenant A might be pinned to EU-West for data residency compliance, while tenant B lives in US-East. This approach aligns naturally with compliance requirements — you can guarantee that all of a tenant's data remains in a specific jurisdiction. It also simplifies billing and resource allocation per region. However, it introduces hot-spot risk: a single large tenant in one region can overwhelm that region's resources while other regions sit underutilized.
| Strategy | Read Latency | Write Latency | Cross-Region Queries | Compliance | Load Balance |
|---|---|---|---|---|---|
| Geo-Based | Low (local reads) | Low (local writes) | Expensive fan-out | Moderate | Good (user distribution) |
| Tenant-Based | Low (pinned to region) | Low (pinned to region) | Expensive fan-out | Excellent (per-tenant isolation) | Risky (hot tenants) |
| Hash-Based (Global) | Variable (random placement) | Variable | Moderate (all regions have data) | Poor (data scattered) | Excellent (uniform distribution) |
| Hybrid | Low (optimizable) | Low | Moderate | Good | Good (tunable) |
A hybrid approach combines geo-based partitioning for user-generated content with tenant-based partitioning for enterprise compliance needs. Non-regulated data is placed by proximity; regulated data is pinned by tenant to the required jurisdiction. This is the most common pattern in production multi-region SaaS systems.
C# implementation of a routing resolver that chooses the correct region for a given piece of data:
C#
public class DataRoutingResolver
{
private readonly ITenantRegistry _tenantRegistry;
private readonly IGeoLocationService _geoService;
public DataRoutingResolver(
ITenantRegistry tenantRegistry,
IGeoLocationService geoService)
{
_tenantRegistry = tenantRegistry;
_geoService = geoService;
}
public string ResolveRegion(DataKey key, string clientIp)
{
// Check if tenant has a pinned region (for compliance)
var tenant = _tenantRegistry.GetTenant(key.TenantId);
if (tenant.PinnedRegion != null)
return tenant.PinnedRegion;
// Check if data has a known home region
if (key.HomeRegion != null)
return key.HomeRegion;
// Default: route based on client geo-location
var location = _geoService.GetRegion(clientIp);
return location switch
{
"NA" => "us-east-1",
"EU" => "eu-west-1",
"AS" => "ap-south-1",
"SA" => "sa-east-1",
_ => "us-east-1"
};
}
}
The routing resolver is invoked at the application layer before every database operation. It first checks for a pinned region (compliance override), then checks the data's home region (if already assigned), and finally falls back to client-based geo-routing. This layered approach ensures compliance is never violated while still optimizing for latency in the common case.
Partitioning strategy also determines your replication topology (Section 5) and conflict resolution approach (Section 7). If data is partitioned cleanly (each piece of data has exactly one home region), conflicts are rare because writes to that data originate from one region. If data is shared across regions, you need robust conflict resolution. The cleaner your partitioning, the simpler your overall architecture.
5. Replication Topologies
Replication is the mechanism by which data written in one region becomes available in other regions. The topology you choose — leader-follower, multi-leader, or leaderless — determines your consistency guarantees, write availability, conflict behavior, and operational complexity. Each topology has a distinct failure mode and set of trade-offs.
Leader-Follower (Primary-Replica)
In a leader-follower topology, one region is the designated leader (primary) for each piece of data. All writes go to the leader, which replicates changes to follower (replica) regions. Followers can serve read queries, but they may be slightly behind the leader due to replication lag. This is the simplest topology and provides strong consistency when reads are served from the leader. The downside is that writes are unavailable if the leader region goes down (unless you promote a follower, which takes time). Most relational databases (PostgreSQL, MySQL, SQL Server) use this model natively.
Multi-Leader (Multi-Primary)
In a multi-leader topology, multiple regions can accept writes for the same data. Changes are propagated asynchronously between leaders. This provides write availability even if one region fails — users can still write to their local leader. The challenge is conflict resolution: if two regions write to the same row concurrently, you need a strategy to reconcile the differences. Multi-leader is common in systems where write availability is paramount, such as collaborative editing tools or distributed CRMs.
Leaderless (Dynamo-style)
In a leaderless topology, any node can accept reads and writes. Writes are sent to multiple nodes, and reads must be reconciled using quorum reads (reading from W nodes and ensuring at least one has the latest write, where W + R > N). This provides the highest availability and tolerance to individual node failures, but at the cost of more complex conflict resolution and the possibility of stale reads. Amazon DynamoDB and Apache Cassandra use variations of this model.
| Topology | Write Availability | Read Consistency | Conflict Risk | Operational Complexity | Best For |
|---|---|---|---|---|---|
| Leader-Follower | Low (leader dependency) | Strong (from leader) | None (single writer) | Low | Most OLTP workloads |
| Multi-Leader | High (any leader writes) | Eventual (cross-leader lag) | High (concurrent writes) | High | Write-heavy, globally collaborative |
| Leaderless | High (any node writes) | Tunable (quorum) | Moderate (reconciliation needed) | High | IoT, high-availability systems |
C# implementation of an abstracted replication manager supporting multiple topologies:
C#
public interface IReplicationStrategy
{
Task ReplicateAsync(ReplicationEntry entry);
}
public class LeaderFollowerReplication : IReplicationStrategy
{
private readonly IEnumerable<IFollowerClient> _followers;
private readonly ILogger<LeaderFollowerReplication> _logger;
public LeaderFollowerReplication(
IEnumerable<IFollowerClient> followers,
ILogger<LeaderFollowerReplication> logger)
{
_followers = followers;
_logger = logger;
}
public async Task ReplicateAsync(ReplicationEntry entry)
{
var tasks = _followers.Select(follower =>
ReplicateToFollowerAsync(follower, entry));
// Fire-and-forget with logging for async replication
await Task.WhenAll(tasks);
_logger.LogInformation(
"Replicated entry {EntryId} to {Count} followers",
entry.Id, _followers.Count());
}
private async Task ReplicateToFollowerAsync(
IFollowerClient follower, ReplicationEntry entry)
{
try
{
await follower.ApplyAsync(entry);
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"Failed to replicate to follower {Endpoint}",
follower.Endpoint);
// Queue for retry with exponential backoff
}
}
}
public class MultiLeaderReplication : IReplicationStrategy
{
private readonly IEnumerable<ILeaderClient> _otherLeaders;
private readonly IConflictResolver _conflictResolver;
public MultiLeaderReplication(
IEnumerable<ILeaderClient> otherLeaders,
IConflictResolver conflictResolver)
{
_otherLeaders = otherLeaders;
_conflictResolver = conflictResolver;
}
public async Task ReplicateAsync(ReplicationEntry entry)
{
foreach (var leader in _otherLeaders)
{
try
{
var remoteVersion = await leader.GetVersionAsync(
entry.EntityId);
if (_conflictResolver.ShouldConverge(
entry, remoteVersion))
{
await leader.ApplyAsync(entry);
}
}
catch (Exception ex)
{
// Log and queue for retry
}
}
}
}
The key design decision in replication is choosing between synchronous and asynchronous replication. Synchronous replication guarantees that every committed write is immediately available in other regions, but it adds latency to every write (the round-trip time to the replica must be included in the commit path). Asynchronous replication provides lower write latency but introduces a window where data may be lost if the leader fails before replicating. In practice, most multi-region systems use synchronous replication within a region (for durability) and asynchronous replication across regions (for latency). Google Spanner is a notable exception — it uses synchronous replication with TrueTime to provide globally consistent reads, at the cost of higher write latency.
6. Consensus Protocols
Consensus protocols ensure that all nodes in a distributed system agree on a single value, even in the presence of failures. This is critical for multi-region databases because it allows the system to elect leaders, commit transactions, and maintain consistency across geographically distributed nodes. The three most important protocols to understand are Raft, Paxos, and EPaxos.
Raft
Raft was designed as a more understandable alternative to Paxos while providing the same guarantees. It decomposes consensus into three sub-problems: leader election, log replication, and safety. A single leader is elected among the nodes; the leader receives client commands, appends them to its log, and replicates them to followers. Once a majority of nodes have acknowledged a log entry, it is committed. Raft is used by etcd, CockroachDB, and TiDB. Its simplicity makes it popular for new database projects. However, Raft requires a stable leader, which means all writes in a Raft group must go through one node — this can be a bottleneck in multi-region setups where that leader may be far from some writers.
Paxos
Paxos is the classic consensus protocol, used in Google Spanner (as Multi-Paxos) and Apache Zookeeper. It is more complex than Raft but allows for optimizations in leader lease management and reconfiguration. Multi-Paxos, as used in Spanner, batches many consensus rounds under a single leader to amortize the cost of leader election. The fundamental guarantee is the same as Raft: once a value is committed (acknowledged by a majority), it cannot be overridden, and all nodes will eventually agree on the same history of committed values.
EPaxos (Egalitarian Paxos)
EPaxos removes the requirement for a stable leader. Any node can propose commands, and consensus is reached through a two-phase commit among a quorum of nodes. Commands that do not conflict with each other can be committed in a single round trip, making EPaxos faster than Raft or Multi-Paxos for non-conflicting workloads in geo-distributed settings. Commands that do conflict go through a slower path. EPaxos is used by some academic and early-production systems (e.g., CockroachDB has explored leaderless approaches inspired by EPaxos) and is particularly attractive for multi-region setups where having a single leader introduces unacceptable latency for some regions.
| Protocol | Leader Requirement | Write Path RTTs | Leader Failure Recovery | Used By |
|---|---|---|---|---|
| Raft | Yes (mandatory) | 1 (same region) / 2+ (cross-region) | Re-election (100ms-2s) | etcd, CockroachDB, TiDB |
| Multi-Paxos | Yes (optimized lease) | 1 (batched under leader lease) | Leader election + lease renewal | Google Spanner, Zookeeper |
| EPaxos | No (any node can propose) | 1 (non-conflicting) / 2 (conflicting) | Instant (no leader to fail) | Academic / experimental |
C# code demonstrating a simplified Raft-style leader election and log replication mechanism:
C#
public class RaftConsensusNode
{
private NodeState _state = NodeState.Follower;
private int _currentTerm = 0;
private string _votedFor = null;
private readonly List<LogEntry> _log = new();
private readonly string _nodeId;
private readonly IEnumerable<IRaftPeer> _peers;
private Timer _electionTimer;
public RaftConsensusNode(
string nodeId, IEnumerable<IRaftPeer> peers)
{
_nodeId = nodeId;
_peers = peers;
ResetElectionTimer();
}
private void ResetElectionTimer()
{
var timeout = Random.Shared.Next(150, 300);
_electionTimer = new Timer(
OnElectionTimeout, null, timeout, Timeout.Infinite);
}
private async void OnElectionTimeout(object state)
{
_state = NodeState.Candidate;
_currentTerm++;
_votedFor = _nodeId;
var votes = 1;
var majority = (_peers.Count() + 1) / 2 + 1;
foreach (var peer in _peers)
{
var granted = await peer.RequestVoteAsync(
_currentTerm, _nodeId, _log.Count,
_log.LastOrDefault()?.Term ?? 0);
if (granted) votes++;
}
if (votes >= majority)
{
_state = NodeState.Leader;
await BeginHeartbeatingAsync();
}
else
{
_state = NodeState.Follower;
ResetElectionTimer();
}
}
public async Task<bool> AppendEntryAsync(LogEntry entry)
{
if (_state != NodeState.Leader) return false;
_log.Add(entry);
var acks = 1;
var majority = (_peers.Count() + 1) / 2 + 1;
var replicationTasks = _peers.Select(peer =>
peer.AppendEntriesAsync(
_currentTerm, _nodeId,
_log.Count - 1,
_log[^2]?.Term ?? 0,
new[] { entry }));
var results = await Task.WhenAll(replicationTasks);
acks += results.Count(r => r);
return acks >= majority;
}
}
public enum NodeState { Follower, Candidate, Leader }
public record LogEntry(int Term, string Command, byte[] Data);
Understanding consensus protocols is essential for multi-region design because they determine how your database handles leader failures, how quickly it can recover, and what consistency guarantees it can provide. When evaluating databases for multi-region deployment, always check which consensus protocol they use and how it performs under cross-region latencies. A protocol that performs well within a single data center may degrade significantly when the consensus round-trip spans 100+ milliseconds between continents.
7. Conflict Resolution
In any multi-region system that allows concurrent writes (multi-leader or leaderless topologies), conflicts are inevitable. A conflict occurs when two or more regions update the same data concurrently, and the updates cannot be automatically merged. How you resolve these conflicts directly impacts data correctness, user experience, and system complexity. There are three primary strategies: Last-Writer-Wins (LWW), Conflict-Free Replicated Data Types (CRDTs), and application-level resolution.
Last-Writer-Wins (LWW)
LWW is the simplest strategy: among concurrent writes, the one with the latest timestamp wins, and all other writes are silently discarded. This is easy to implement and understand, but it can lose data — a legitimate write from one region may be overwritten by a later write from another region. LWW works well when conflicts are rare (e.g., a user only edits their own profile, and two concurrent edits from the same user are unlikely) or when losing a concurrent update is acceptable (e.g., a "last seen" timestamp). The challenge with LWW is clock synchronization: if clocks are not perfectly synchronized, a write with an earlier actual time may have a later timestamp and incorrectly "win." Google Spanner's TrueTime solves this by using atomic clocks and GPS to provide globally synchronized timestamps with bounded uncertainty.
CRDTs (Conflict-Free Replicated Data Types)
CRDTs are data structures designed so that concurrent updates can be merged automatically without conflicts, while always converging to the same state. For example, a G-Counter (grow-only counter) can be implemented by maintaining a per-node counter and merging by taking the maximum of each node's counter. A PN-Counter (positive-negative counter) supports both increment and decrement by using two G-Counters. OR-Sets (Observed-Remove Sets) allow concurrent add and remove operations that converge correctly. CRDTs are powerful because they eliminate the need for conflict resolution — the data structure itself guarantees convergence. The trade-off is that CRDTs are more complex to implement correctly, have higher memory overhead (storing per-node state), and are not suitable for all data types.
Application-Level Resolution
Application-level resolution uses business logic to merge conflicts. For example, in a shopping cart system, the merge operation is the union of items from both carts — if one region added item A and another added item B, the merged cart has both A and B. This approach provides the most correct behavior for the specific use case, but it requires custom code for each entity type and must be maintained as the application evolves. It is also the hardest to test and verify.
| Strategy | Data Loss Risk | Implementation Complexity | Performance | Correctness | Best For |
|---|---|---|---|---|---|
| LWW | High (overwrites) | Low | High | Low-Medium | Tolerant fields (timestamps, status) |
| CRDTs | None (convergent) | High | Medium | High (for CRDT-compatible ops) | Counters, sets, flags |
| Application-Level | None (if logic is correct) | High (per entity) | Medium | Highest (domain-specific) | Shopping carts, collaborative editing |
C# implementation of CRDT-based conflict resolution for a distributed counter:
C#
public class GCounterCrdt
{
private readonly Dictionary<string, long> _counts = new();
private readonly string _nodeId;
public GCounterCrdt(string nodeId)
{
_nodeId = nodeId;
}
public void Increment(long amount = 1)
{
if (!_counts.ContainsKey(_nodeId))
_counts[_nodeId] = 0;
_counts[_nodeId] += amount;
}
public long Value => _counts.Values.Sum();
public void Merge(GCounterCrdt other)
{
foreach (var kvp in other._counts)
{
if (!_counts.ContainsKey(kvp.Key))
_counts[kvp.Key] = kvp.Value;
else
_counts[kvp.Key] = Math.Max(
_counts[kvp.Key], kvp.Value);
}
}
public Dictionary<string, long> Serialize() => new(_counts);
public static GCounterCrdt Deserialize(
string nodeId,
Dictionary<string, long> data)
{
var counter = new GCounterCrdt(nodeId);
foreach (var kvp in data)
counter._counts[kvp.Key] = kvp.Value;
return counter;
}
}
public class ConflictResolutionService
{
private readonly Dictionary<string, IConflictResolver>
_resolversByEntity;
public ConflictResolutionService(
Dictionary<string, IConflictResolver> resolversByEntity)
{
_resolversByEntity = resolversByEntity;
}
public MergedResult Resolve(
string entityType,
ConflictPair conflict)
{
if (!_resolversByEntity.TryGetValue(
entityType, out var resolver))
{
// Default to LWW
return conflict.Local.Timestamp
> conflict.Remote.Timestamp
? new MergedResult(conflict.Local.Value, Source.Local)
: new MergedResult(conflict.Remote.Value, Source.Remote);
}
return resolver.Merge(conflict);
}
}
public record MergedResult(object Value, Source Winner);
public enum Source { Local, Remote, Merged }
public interface IConflictResolver
{
MergedResult Merge(ConflictPair conflict);
}
The choice of conflict resolution strategy should be driven by your data model and business requirements. In practice, most systems use a combination: LWW for non-critical metadata fields, CRDTs for counters and distributed state, and application-level resolution for critical business entities. Document your conflict resolution strategy for each entity type — this is essential knowledge for on-call engineers debugging data inconsistencies in production.
8. Cross-Region Latency and Bandwidth
Understanding cross-region latency is fundamental to multi-region design. The speed of light imposes a hard physical limit on how fast data can travel between regions. A round trip between US-East and EU-West takes approximately 70-80 milliseconds. US-East to AP-South is around 180-220 milliseconds. EU-West to AP-South is around 130-170 milliseconds. These are minimums; real-world latency includes routing overhead, queuing, and processing, often adding 20-50% to the theoretical minimum. Every architectural decision — synchronous vs. async replication, leader placement, read routing — is constrained by these latencies.
| Route | Minimum RTT (ms) | Typical RTT (ms) | Throughput (Gbps) |
|---|---|---|---|
| US-East ↔ US-West | 60 | 70-90 | 10-100 |
| US-East ↔ EU-West | 70 | 80-110 | 10-50 |
| US-East ↔ AP-South | 170 | 200-250 | 5-20 |
| EU-West ↔ AP-South | 130 | 150-200 | 5-20 |
| US-East ↔ SA-East | 100 | 120-160 | 5-15 |
| EU-West ↔ AP-East | 140 | 160-210 | 5-15 |
These numbers have concrete implications. If you use synchronous replication across regions, every write pays the cross-region round-trip penalty. A write from US-East with synchronous replication to EU-West adds 80+ ms to every commit. If your SLA requires p99 latency under 200ms, synchronous cross-region replication may consume your entire latency budget on the write path alone. This is why most multi-region systems use synchronous replication within a region (intra-region RTT is 1-5ms) and asynchronous replication across regions.
Bandwidth is also a consideration, though less frequently a bottleneck. Cross-region bandwidth costs money (AWS charges $0.02/GB for data transfer between regions) and has finite capacity. A 100GB database that needs to be replicated to three regions requires 300GB of cross-region transfer on initial sync, plus ongoing replication traffic for every write. At scale, replication bandwidth can become a significant cost center and may require compression, delta-only replication, or selective replication (only replicate hot data).
C# code for a latency-aware routing decision engine:
C#
public class LatencyAwareRouter
{
private readonly Dictionary<string, LatencyMatrix> _matrix;
private readonly IMetricsCollector _metrics;
public LatencyAwareRouter(
Dictionary<string, LatencyMatrix> matrix,
IMetricsCollector metrics)
{
_matrix = matrix;
_metrics = metrics;
}
public async Task<string> RouteReadAsync(
string clientRegion, ConsistencyLevel level)
{
if (level == ConsistencyLevel.Strong)
{
// Must read from leader region
return GetLeaderRegion();
}
// For eventual consistency, find lowest-latency region
var candidates = _matrix[clientRegion]
.RegionLatencies
.Where(r => r.Value.IsHealthy)
.OrderBy(r => r.Value.P99LatencyMs);
var best = candidates.First();
_metrics.RecordRoutingDecision(
clientRegion, best.Key, best.Value.P99LatencyMs);
return best.Key;
}
public async Task<string> RouteWriteAsync(string clientRegion)
{
// Writes go to the leader of the partition
return GetLeaderRegion();
}
private string GetLeaderRegion() => "us-east-1";
}
public class LatencyMatrix
{
public Dictionary<string, RegionLatency> RegionLatencies
{ get; set; } = new();
}
public class RegionLatency
{
public double P50LatencyMs { get; set; }
public double P99LatencyMs { get; set; }
public bool IsHealthy { get; set; }
}
Reducing cross-region latency requires creative architectural solutions: placing read replicas close to users, caching aggressively at the edge, batching writes to amortize replication costs, and designing data models that minimize cross-region dependencies. The physical constraints cannot be eliminated, but they can be managed through careful design. Every millisecond saved on the hot path translates directly to better user experience and higher throughput.
9. Global Load Balancing
Global load balancing directs client requests to the optimal region based on latency, health, and capacity. There are three primary mechanisms: Anycast, GeoDNS, and latency-based routing. Each operates at a different layer of the network stack and has distinct trade-offs in granularity, failover speed, and configuration complexity.
Anycast
Anycast advertises the same IP address from multiple data centers. Border Gateway Protocol (BGP) routes each request to the nearest data center based on network topology. Anycast provides instant failover — if one data center goes down, BGP withdraws the route and traffic automatically shifts to the next-closest data center. It operates at the network layer, so it works for any TCP/UDP traffic, including database connections. Cloudflare and most CDN providers use Anycast. The downside is that Anycast routing is based on network topology, not application-level metrics like latency or load — the "nearest" data center by network hops may not be the fastest.
GeoDNS
GeoDNS resolves a domain name to different IP addresses based on the client's geographic location. It is simple to configure and works at the DNS layer, so it requires no changes to application code. However, DNS has long TTLs (typically 300-600 seconds), so failover is slow — during that window, some clients will still be directed to a failed region. GeoDNS also has limited granularity — it typically routes at the country or continent level, not the city level.
Latency-Based Routing
Latency-based routing uses real-time latency measurements to direct clients to the fastest healthy region. AWS Route 53 latency-based routing and similar services periodically measure latency from various locations and update DNS records or routing tables accordingly. This provides the most accurate routing but adds complexity and may not converge as quickly as Anycast during failures.
| Mechanism | Failover Speed | Granularity | Layer | Configuration | Cost |
|---|---|---|---|---|---|
| Anycast | Instant (BGP convergence) | Network topology | L3/L4 | Complex (BGP management) | High |
| GeoDNS | Slow (DNS TTL: 5-10 min) | Country/Continent | L3 (DNS) | Simple | Low |
| Latency-Based | Medium (30s-2min) | City-level | L3-L7 | Moderate | Medium |
| Client-Side (SDK) | Instant (local decision) | Per-request | L7 (Application) | Complex (SDK needed) | Low |
C# implementation of a client-side latency-aware load balancer that makes per-request routing decisions:
C#
public class ClientSideLoadBalancer
{
private readonly Dictionary<string, RegionEndpoint> _endpoints;
private readonly LatencyTracker _latencyTracker;
private readonly CircuitBreakerRegistry _circuitBreakers;
public ClientSideLoadBalancer(
Dictionary<string, RegionEndpoint> endpoints,
LatencyTracker latencyTracker,
CircuitBreakerRegistry circuitBreakers)
{
_endpoints = endpoints;
_latencyTracker = latencyTracker;
_circuitBreakers = circuitBreakers;
}
public RegionEndpoint SelectRegion(
string clientRegion, string operationType)
{
var candidates = _endpoints
.Where(e => !_circuitBreakers
.Get(e.Key).IsOpen)
.OrderBy(e =>
CalculateScore(e.Value, clientRegion, operationType))
.ToList();
if (candidates.Count == 0)
throw new NoHealthyRegionAvailableException();
return candidates.First().Value;
}
private double CalculateScore(
RegionEndpoint endpoint,
string clientRegion,
string operationType)
{
var latencyScore = _latencyTracker
.GetP95(clientRegion, endpoint.RegionId);
var loadScore = endpoint.CurrentLoadPercent;
// For reads, heavily weight latency
// For writes, balance latency and load
return operationType == "read"
? latencyScore * 0.8 + loadScore * 0.2
: latencyScore * 0.5 + loadScore * 0.5;
}
}
public class RegionEndpoint
{
public string RegionId { get; set; }
public string ConnectionString { get; set; }
public double CurrentLoadPercent { get; set; }
}
public class CircuitBreaker
{
private int _failureCount;
private DateTime _lastFailure;
private readonly int _threshold;
private readonly TimeSpan _recoveryTime;
public CircuitBreaker(int threshold, TimeSpan recoveryTime)
{
_threshold = threshold;
_recoveryTime = recoveryTime;
}
public bool IsOpen =>
_failureCount >= _threshold &&
DateTime.UtcNow - _lastFailure < _recoveryTime;
public void RecordFailure()
{
_failureCount++;
_lastFailure = DateTime.UtcNow;
}
public void Reset() => _failureCount = 0;
}
In practice, most production systems combine multiple load balancing mechanisms: Anycast or GeoDNS for coarse-grained global routing, and client-side latency-based routing for fine-grained per-request optimization. This layered approach provides both fast failover (via Anycast/BGP) and optimal latency under normal operation (via client-side decisions). The key is ensuring that all layers agree on region health — a region that is removed from DNS should also be excluded from client-side routing decisions, and vice versa.
10. Data Locality and Read-After-Write Consistency
Data locality — ensuring that the data a user needs is physically close to where they are accessing it — is the primary motivation for multi-region design. However, locality introduces a fundamental tension with consistency: if data is replicated asynchronously, a user who writes to one region and immediately reads from another may see stale data. This is the read-after-write consistency problem, and it requires specific architectural solutions.
The most common solution is session stickiness: ensure that a user's reads and writes always go to the same region for the duration of a session. This guarantees that reads within a session always see the latest writes because they hit the same primary. Session stickiness is implemented at the load balancer level (using IP-based affinity or session cookies) or at the application level (storing the user's region in their session state). The downside is that if that region fails, the session must be re-established in a new region, and the user may briefly see stale data.
A more sophisticated approach is read-your-writes consistency: after a write, the system tracks the write's timestamp or version and ensures that subsequent reads return data at least as recent as that version. This can be implemented by including the write timestamp in the client's session and having the read path check whether the local replica is up to date. If not, the read is forwarded to the leader. This provides a better user experience than strict session stickiness because it works even if the user's requests are load-balanced across regions, but it adds complexity to the read path.
A third approach is causal consistency, which ensures that causally related operations are seen in order. If operation A causes operation B (e.g., a user creates an order, then views the order), B will always see the effects of A, regardless of which region serves the read. This is achieved by propagating causal metadata (vector clocks or version vectors) with each operation and having the read path wait until the local replica has caught up to the causal dependency. Causal consistency is strictly stronger than eventual consistency but strictly weaker than linearizability, and it provides a good balance of performance and correctness for most applications.
C# implementation of read-your-writes consistency tracking:
C#
public class ReadYourWritesSession
{
private readonly string _sessionId;
private readonly string _writeRegion;
private long _lastWriteTimestamp;
private readonly Dictionary<string, long> _partitionVersions;
public ReadYourWritesSession(string sessionId, string writeRegion)
{
_sessionId = sessionId;
_writeRegion = writeRegion;
_partitionVersions = new Dictionary<string, long>();
}
public void RecordWrite(string partitionKey, long timestamp)
{
_lastWriteTimestamp = Math.Max(
_lastWriteTimestamp, timestamp);
_partitionVersions[partitionKey] = timestamp;
}
public async Task<T> ReadAsync<T>(
string partitionKey,
Func<Task<T>> localRead,
Func<long, Task<T>> leaderRead)
{
if (_partitionVersions.TryGetValue(
partitionKey, out var requiredVersion))
{
var localVersion = await GetLocalVersionAsync(
partitionKey);
if (localVersion >= requiredVersion)
{
return await localRead();
}
// Local replica is stale — read from leader
return await leaderRead(requiredVersion);
}
// No prior write in this session — local read is fine
return await localRead();
}
private Task<long> GetLocalVersionAsync(string partitionKey)
{
// Query local replica's version for the partition
return Task.FromResult(0L);
}
}
public class ConsistencyGuarantee
{
private readonly ReadYourWritesSession _session;
public ConsistencyGuarantee(ReadYourWritesSession session)
{
_session = session;
}
public async Task<Order> GetOrderAsync(string orderId)
{
return await _session.ReadAsync(
orderId,
localRead: () => ReadFromLocalReplicaAsync(orderId),
leaderRead: version =>
ReadFromLeaderAsync(orderId, version));
}
private Task<Order> ReadFromLocalReplicaAsync(string orderId)
=> Task.FromResult(new Order { Id = orderId });
private Task<Order> ReadFromLeaderAsync(
string orderId, long sinceVersion)
=> Task.FromResult(new Order { Id = orderId });
}
public class Order
{
public string Id { get; set; }
}
Read-after-write consistency is one of the most common interview questions in multi-region system design. Be prepared to discuss session stickiness, read-your-writes tracking, and causal consistency — and to articulate the trade-offs between them. In practice, session stickiness is the simplest to implement and sufficient for most applications. Read-your-writes provides a better user experience at the cost of occasional leader-round-trips. Causal consistency is the most powerful but also the most complex.
11. Schema Management Across Regions
Schema changes in a multi-region database are significantly more complex than in a single-region system. A schema migration must be applied to all regions, but you cannot simply run the migration everywhere at once — doing so would create a window where some regions have the new schema while others have the old one, potentially breaking cross-region replication. The standard approach is expand-and-contract (also called parallel change): first add new columns/tables without removing old ones (expand), deploy code that uses both old and new schemas, then remove the old columns/tables (contract) after all regions have the new schema.
For multi-region systems, the expand-and-contract process must be coordinated across regions. The typical sequence is: (1) Apply the expand migration to all regions, starting with the least critical and ending with the leader. (2) Deploy application code that is compatible with both old and new schemas. (3) Wait for all regions to be running the new code. (4) Apply the contract migration to remove deprecated columns. This process can take days or weeks in a large system, and each step must be idempotent and safe to retry.
Tools like Flyway, Liquibase, and Alembic can manage schema migrations, but in a multi-region context, they need to be wrapped in a coordination layer that tracks migration state per region and ensures that each step is applied in the correct order. Many teams build a custom migration orchestrator that queries each region's schema version, decides what migrations to apply, and executes them with appropriate locking and rollback support.
C# example of a multi-region schema migration orchestrator:
C#
public class MultiRegionMigrationOrchestrator
{
private readonly IEnumerable<IRegionMigrationClient> _regions;
private readonly IMigrationPlan _plan;
private readonly ILogger<MultiRegionMigrationOrchestrator> _logger;
public MultiRegionMigrationOrchestrator(
IEnumerable<IRegionMigrationClient> regions,
IMigrationPlan plan,
ILogger<MultiRegionMigrationOrchestrator> logger)
{
_regions = regions;
_plan = plan;
_logger = logger;
}
public async Task ExecuteMigrationAsync(Migration migration)
{
// Phase 1: Apply expand to all non-leader regions
var followers = _regions.Where(r => !r.IsLeader);
foreach (var region in followers)
{
_logger.LogInformation(
"Applying expand to {Region}", region.Name);
await region.ApplyMigrationAsync(migration.Expand);
}
// Phase 2: Apply expand to leader (last)
var leader = _regions.First(r => r.IsLeader);
_logger.LogInformation(
"Applying expand to leader {Region}", leader.Name);
await leader.ApplyMigrationAsync(migration.Expand);
// Phase 3: Verify all regions on same version
await VerifyAllRegionsSynchronizedAsync(migration.Expand);
// Phase 4: Apply contract after code deployment
_logger.LogInformation(
"All regions synchronized. Contract migration ready.");
// Contract is applied in reverse order: leader first
await leader.ApplyMigrationAsync(migration.Contract);
foreach (var region in followers)
{
await region.ApplyMigrationAsync(migration.Contract);
}
}
private async Task VerifyAllRegionsSynchronizedAsync(
SchemaVersion expectedVersion)
{
foreach (var region in _regions)
{
var version = await region.GetCurrentVersionAsync();
if (version != expectedVersion)
{
throw new RegionNotSynchronizedException(
region.Name, expectedVersion, version);
}
}
}
}
public class Migration
{
public SchemaOperation Expand { get; set; }
public SchemaOperation Contract { get; set; }
}
public class SchemaOperation
{
public string Id { get; set; }
public string Sql { get; set; }
}
Schema management is often overlooked in multi-region design discussions, but it is one of the most operationally complex aspects of running a globally distributed database. A poorly managed schema migration can cause replication failures, data corruption, or prolonged outages. Invest in automation and tooling early — the cost of building a robust migration orchestrator is small compared to the cost of a botched manual migration.
12. Distributed Transactions
In a single-region database, a transaction provides ACID guarantees — atomicity, consistency, isolation, and durability — within a single machine. In a multi-region system, transactions that span multiple regions face additional challenges: network latency between regions makes two-phase commit slow, failures during the commit protocol can leave the system in an inconsistent state, and locking across regions increases contention. There are three primary patterns for distributed transactions: Two-Phase Commit (2PC), the Saga pattern, and TCC (Try-Confirm/Cancel).
Two-Phase Commit (2PC)
2PC is the classic distributed transaction protocol. A coordinator sends a "prepare" message to all participants; each participant votes "yes" (ready to commit) or "no" (abort). If all participants vote yes, the coordinator sends a "commit" message; otherwise, it sends an "abort." 2PC provides strict atomicity — either all participants commit or none do. The problem is that 2PC blocks during the prepare phase: if a participant crashes after voting yes but before receiving the commit message, it holds locks until it recovers. In a multi-region setup, the prepare phase adds at least one cross-region round trip, and the blocking behavior can cascade across regions. 2PC is best used when transactions are short, participants are reliable, and cross-region communication is fast (e.g., within a cloud provider's backbone).
Saga Pattern
The Saga pattern decomposes a distributed transaction into a sequence of local transactions, each with a compensating action. If any step fails, the compensating actions for all completed steps are executed in reverse order. Sagas do not provide isolation — intermediate states are visible to other transactions — but they avoid the blocking behavior of 2PC and work well for long-running business processes. Each step in the saga runs as a local transaction in a single region, so there is no cross-region locking. The compensating actions must be idempotent and should be designed to handle partial failures gracefully.
TCC (Try-Confirm/Cancel)
TCC is a middle ground between 2PC and Sagas. Each participant exposes three operations: Try (reserve resources), Confirm (finalize the reservation), and Cancel (release the reservation). The coordinator first calls Try on all participants; if all succeed, it calls Confirm on all; if any fail, it calls Cancel on all. TCC provides better isolation than Sagas (resources are reserved during the Try phase) and avoids the blocking behavior of 2PC (no long-held locks). The trade-off is that each participant must implement three operations instead of one, increasing implementation complexity.
| Pattern | Atomicity | Isolation | Latency | Blocking | Complexity | Best For |
|---|---|---|---|---|---|---|
| 2PC | Strong | Strong | High (2x cross-region RTT) | Yes (locks held) | Medium | Short, critical transactions |
| Saga | Eventual (via compensation) | None (intermediate visible) | Low (local transactions) | No | High (compensating logic) | Long-running business processes |
| TCC | Strong (with reservation) | Partial (reserved resources) | Medium (1x cross-region RTT) | No | High (3 ops per participant) | Multi-region inventory, booking |
C# implementation of a Saga orchestrator for cross-region order processing:
C#
public class SagaOrchestrator
{
private readonly List<SagaStep> _steps = new();
private readonly ILogger<SagaOrchestrator> _logger;
public SagaOrchestrator(ILogger<SagaOrchestrator> logger)
{
_logger = logger;
}
public void AddStep(
Func<Task> execute,
Func<Task> compensate)
{
_steps.Add(new SagaStep(execute, compensate));
}
public async Task<SagaResult> ExecuteAsync()
{
var completedSteps = new List<SagaStep>();
foreach (var step in _steps)
{
try
{
_logger.LogInformation(
"Executing saga step {StepIndex}",
completedSteps.Count);
await step.Execute();
completedSteps.Add(step);
}
catch (Exception ex)
{
_logger.LogError(ex,
"Saga step failed. Rolling back.");
// Compensate in reverse order
for (int i = completedSteps.Count - 1; i >= 0; i--)
{
try
{
await completedSteps[i].Compensate();
}
catch (Exception compEx)
{
_logger.LogError(compEx,
"Compensation failed for step {Index}", i);
// Compensation failure is critical
// — requires manual intervention
}
}
return new SagaResult
{
Success = false,
FailedStepIndex = completedSteps.Count,
Error = ex.Message
};
}
}
return new SagaResult { Success = true };
}
}
public class SagaStep
{
public Func<Task> Execute { get; }
public Func<Task> Compensate { get; }
public SagaStep(Func<Task> execute, Func<Task> compensate)
{
Execute = execute;
Compensate = compensate;
}
}
public class SagaResult
{
public bool Success { get; set; }
public int FailedStepIndex { get; set; }
public string Error { get; set; }
}
// Example: Cross-region order placement saga
public class OrderSagaFactory
{
public SagaOrchestrator CreateOrderSaga(
Order order, string inventoryRegion, string paymentRegion)
{
var saga = new SagaOrchestrator(
new LoggerFactory().CreateLogger<SagaOrchestrator>());
saga.AddStep(
execute: () => ReserveInventoryAsync(
order, inventoryRegion),
compensate: () => ReleaseInventoryAsync(
order, inventoryRegion));
saga.AddStep(
execute: () => ProcessPaymentAsync(
order, paymentRegion),
compensate: () => RefundPaymentAsync(
order, paymentRegion));
saga.AddStep(
execute: () => ConfirmOrderAsync(order),
compensate: () => CancelOrderAsync(order));
return saga;
}
private Task ReserveInventoryAsync(
Order order, string region) => Task.CompletedTask;
private Task ReleaseInventoryAsync(
Order order, string region) => Task.CompletedTask;
private Task ProcessPaymentAsync(
Order order, string region) => Task.CompletedTask;
private Task RefundPaymentAsync(
Order order, string region) => Task.CompletedTask;
private Task ConfirmOrderAsync(Order order) => Task.CompletedTask;
private Task CancelOrderAsync(Order order) => Task.CompletedTask;
}
When choosing a distributed transaction pattern, consider the business tolerance for inconsistency, the latency budget, and the complexity your team can maintain. For most multi-region systems, Sagas are the pragmatic choice: they avoid cross-region blocking, decompose complex transactions into manageable steps, and provide a clear compensation path for failures. 2PC is appropriate only for short, critical transactions where strict atomicity is non-negotiable and cross-region latency is acceptable. TCC is a good middle ground for scenarios like distributed inventory management where resource reservation is essential.
13. Disaster Recovery and Failover
Disaster recovery (DR) in a multi-region system goes beyond backups — it requires the ability to continue operating with acceptable degradation when an entire region becomes unavailable. There are three key metrics that define your DR posture: RPO (Recovery Point Objective, how much data you can afford to lose), RTO (Recovery Time Objective, how long you can afford to be degraded), and the failover strategy (automatic vs. manual, and how traffic is redirected).
A multi-region system with asynchronous replication has a non-zero RPO: if the leader region fails before replicating its latest writes, those writes are lost. The RPO equals the replication lag at the time of failure. If replication lag is typically 100ms but spikes to 5 seconds under load, your RPO could be up to 5 seconds. To achieve zero RPO, you need synchronous replication — but this comes at the cost of higher write latency. The trade-off between RPO and write latency is one of the fundamental tensions in multi-region design.
Failover can be automatic (the system detects a failure and redirects traffic within seconds) or manual (an on-call engineer initiates failover). Automatic failover is faster but risks false positives — if the system incorrectly declares a region dead, it may cause a "split brain" scenario where two regions think they are the leader. Manual failover is safer but slower. Most production systems use automatic failover with strong safeguards: quorum-based failure detection, health check thresholds, and "fencing tokens" that prevent a recovered former leader from accepting writes after a new leader has been elected.
Post-disaster data recovery involves two phases: failover (redirect traffic to a healthy region) and recovery (restore the failed region and resynchronize data). The recovery phase may take hours if the database is large, and the recovered region will have a different state than the current leader. Careful handling of the recovery phase is essential to avoid data loss or inconsistency.
C# implementation of an automatic failover controller:
C#
public class FailoverController
{
private readonly Dictionary<string, RegionHealth> _regions;
private readonly IFailoverPolicy _policy;
private readonly IFailoverExecutor _executor;
private readonly ILogger<FailoverController> _logger;
private string _activeLeader;
public FailoverController(
Dictionary<string, RegionHealth> regions,
IFailoverPolicy policy,
IFailoverExecutor executor,
ILogger<FailoverController> logger)
{
_regions = regions;
_policy = policy;
_executor = executor;
_logger = logger;
}
public async Task CheckAndFailoverAsync()
{
var leaderHealth = _regions[_activeLeader];
if (!leaderHealth.IsUnhealthy)
return;
_logger.LogCritical(
"Leader region {Region} is unhealthy. " +
"Evaluating failover.", _activeLeader);
var failures = leaderHealth.ConsecutiveFailures;
if (failures < _policy.RequiredFailures)
{
_logger.LogWarning(
"Failure count {Count} below threshold {Threshold}",
failures, _policy.RequiredFailures);
return;
}
// Select new leader from healthy regions
var candidates = _regions
.Where(r => r.Key != _activeLeader
&& !r.Value.IsUnhealthy
&& r.Value.IsEligibleForPromotion)
.OrderBy(r => r.Value.ReplicationLagMs)
.ToList();
if (candidates.Count == 0)
{
_logger.LogCritical(
"No healthy regions available for failover!");
return;
}
var newLeader = candidates.First().Key;
_logger.LogCritical(
"Failing over from {Old} to {New}",
_activeLeader, newLeader);
await _executor.ExecuteFailoverAsync(
_activeLeader, newLeader);
_activeLeader = newLeader;
}
}
public class FailoverPolicy
{
public int RequiredFailures { get; set; } = 3;
public TimeSpan CheckInterval { get; set; } =
TimeSpan.FromSeconds(10);
public bool RequireQuorum { get; set; } = true;
}
public class RegionHealth
{
public bool IsUnhealthy { get; set; }
public int ConsecutiveFailures { get; set; }
public long ReplicationLagMs { get; set; }
public bool IsEligibleForPromotion { get; set; }
}
Regular DR testing is non-negotiable. Schedule quarterly "game days" where you simulate region failures and verify that failover works correctly. Measure the actual RTO and RPO during these tests. Many organizations discover during their first DR drill that their failover process has bugs, missing steps, or assumptions that don't hold under real failure conditions. The time to discover these issues is in a controlled test, not during a real disaster.
14. Compliance and Data Sovereignty
Data sovereignty regulations — GDPR in Europe, LGPD in Brazil, PIPL in China, CCPA in California — impose strict requirements on where personal data can be stored, processed, and transferred. A multi-region database design must account for these requirements from the start, as retrofitting compliance into an existing global architecture is significantly more expensive and risky than designing for it upfront.
GDPR (General Data Protection Regulation) is the most comprehensive data protection regulation. Key requirements relevant to multi-region database design include: (1) Personal data of EU residents must be stored and processed within the EU unless adequate safeguards are in place. (2) Users have the right to access, correct, and delete their data. (3) Data breaches must be reported within 72 hours. (4) Cross-border data transfers require legal mechanisms such as Standard Contractual Clauses (SCCs) or adequacy decisions. For a multi-region database, this means EU user data cannot be replicated to US or AP-South regions without appropriate legal and technical safeguards.
LGPD (Brazil), PIPL (China), and other national regulations add their own requirements, some more restrictive than GDPR. China's PIPL, for example, requires that personal information of Chinese citizens be stored within China and that cross-border transfers undergo a security assessment. This effectively mandates a dedicated Chinese region with no replication outside the country, or very tightly controlled replication with encryption and legal review.
Implementation requires a combination of technical controls (data partitioning, encryption, access controls) and organizational controls (policies, audit procedures, legal review). Technical controls include tenant-based data partitioning (pinned to the required region), row-level security (preventing queries from accessing data outside the allowed jurisdiction), and audit logging (recording all data access for compliance reviews).
C# implementation of a compliance-aware data access layer:
C#
public class ComplianceAwareDataAccess
{
private readonly DataRoutingResolver _routingResolver;
private readonly IAuditLogger _auditLogger;
private readonly IEncryptionService _encryption;
public ComplianceAwareDataAccess(
DataRoutingResolver routingResolver,
IAuditLogger auditLogger,
IEncryptionService encryption)
{
_routingResolver = routingResolver;
_auditLogger = auditLogger;
_encryption = encryption;
}
public async Task<T> ReadAsync<T>(
DataKey key,
string clientRegion,
string userId,
DataClassification classification)
{
// Enforce data residency
var allowedRegions = classification
.AllowedRegions;
var targetRegion = _routingResolver
.ResolveRegion(key, clientRegion);
if (!allowedRegions.Contains(targetRegion))
{
throw new DataResidencyViolationException(
$"Data for {key} cannot be accessed from " +
$"{targetRegion}. Allowed: " +
$"{string.Join(", ", allowedRegions)}");
}
// Log access for audit trail
await _auditLogger.LogAccessAsync(new AuditEntry
{
UserId = userId,
DataKey = key,
AccessRegion = targetRegion,
Timestamp = DateTime.UtcNow,
Classification = classification.Level
});
// Read from appropriate region
return await ReadFromRegionAsync<T>(
key, targetRegion, classification);
}
private Task<T> ReadFromRegionAsync<T>(
DataKey key, string region,
DataClassification classification)
{
// Decrypt if needed for the classification level
return Task.FromResult(default(T));
}
}
public class DataClassification
{
public string Level { get; set; } // "Public", "Internal", "PII", "Sensitive"
public List<string> AllowedRegions { get; set; }
public bool RequiresEncryption { get; set; }
public int RetentionDays { get; set; }
}
public class DataResidencyViolationException : Exception
{
public DataResidencyViolationException(string message)
: base(message) { }
}
Compliance is not a one-time project — it is an ongoing process. Regulations change, new jurisdictions add requirements, and your data handling practices evolve. Build compliance checks into your CI/CD pipeline (validate that new code does not inadvertently replicate data outside allowed regions), maintain an up-to-date data catalog that maps each data type to its classification and allowed regions, and conduct regular compliance audits. The cost of non-compliance — GDPR fines can reach 4% of global annual revenue — far exceeds the cost of building compliance into your architecture from the start.
15. Monitoring and Observability for Global Systems
Operating a multi-region database without comprehensive monitoring is like flying a plane without instruments — you may be fine until you are not, and by then it is too late. Global systems require monitoring at multiple levels: infrastructure (CPU, memory, disk, network), database (replication lag, query latency, connection pool saturation), application (request latency, error rates, throughput), and business (data freshness, consistency violations, compliance events).
The four pillars of observability — metrics, logs, traces, and events — are all essential for multi-region systems. Metrics give you a real-time view of system health across regions. Logs provide detailed context for debugging specific issues. Distributed traces follow a request across multiple regions and services, revealing where latency is spent. Events capture important state changes like failovers, schema migrations, and configuration updates.
Cross-region observability introduces specific challenges. First, monitoring data itself must be replicated — if you can only see metrics from the US-East region, you cannot detect problems in EU-West. Most teams use a centralized monitoring system (Grafana, Datadog, Prometheus with Thanos) that aggregates metrics from all regions. Second, cross-region operations involve multiple hops, so traces must span regions. OpenTelemetry provides the standard for distributed tracing across regions. Third, alerting must account for regional differences — a 50ms p99 latency is normal for US-East to EU-West replication but alarming for intra-region queries.
| Metric | Normal Range | Warning Threshold | Critical Threshold | Measurement |
|---|---|---|---|---|
| Replication Lag (intra-region) | < 5ms | > 50ms | > 500ms | Heartbeat-based measurement |
| Replication Lag (cross-region) | < 200ms | > 1s | > 10s | Heartbeat-based measurement |
| Write Latency (p99) | < 50ms | > 200ms | > 1s | Application-level timing |
| Read Latency (p99) | < 30ms | > 100ms | > 500ms | Application-level timing |
| Connection Pool Utilization | < 60% | > 80% | > 95% | Connection pool metrics |
| Disk Usage | < 60% | > 80% | > 90% | OS-level metrics |
C# implementation of a cross-region health aggregator that collects and synthesizes health signals:
C#
public class GlobalHealthAggregator
{
private readonly IEnumerable<IRegionHealthSource> _sources;
private readonly IAlertManager _alertManager;
private readonly TimeSpan _evaluationWindow;
public GlobalHealthAggregator(
IEnumerable<IRegionHealthSource> sources,
IAlertManager alertManager,
TimeSpan evaluationWindow)
{
_sources = sources;
_alertManager = alertManager;
_evaluationWindow = evaluationWindow;
}
public async Task<GlobalHealthReport> EvaluateAsync()
{
var regionReports = new List<RegionHealthReport>();
foreach (var source in _sources)
{
var metrics = await source
.GetMetricsAsync(_evaluationWindow);
var report = new RegionHealthReport
{
RegionName = source.RegionName,
ReplicationLagMs = metrics.ReplicationLagMs,
WriteLatencyP99Ms = metrics.WriteLatencyP99Ms,
ReadLatencyP99Ms = metrics.ReadLatencyP99Ms,
ConnectionPoolPercent =
metrics.ConnectionPoolPercent,
DiskUsagePercent = metrics.DiskUsagePercent,
IsHealthy = metrics.ReplicationLagMs < 5000
&& metrics.WriteLatencyP99Ms < 1000
&& metrics.DiskUsagePercent < 90
};
regionReports.Add(report);
if (!report.IsHealthy)
{
await _alertManager.SendAlertAsync(
AlertSeverity.Warning,
$"Region {source.RegionName} is unhealthy",
report);
}
}
var healthyCount = regionReports
.Count(r => r.IsHealthy);
return new GlobalHealthReport
{
Regions = regionReports,
OverallHealthy = healthyCount >=
regionReports.Count / 2,
HealthyRegionCount = healthyCount,
TotalRegionCount = regionReports.Count
};
}
}
public class RegionHealthReport
{
public string RegionName { get; set; }
public long ReplicationLagMs { get; set; }
public double WriteLatencyP99Ms { get; set; }
public double ReadLatencyP99Ms { get; set; }
public double ConnectionPoolPercent { get; set; }
public double DiskUsagePercent { get; set; }
public bool IsHealthy { get; set; }
}
public class GlobalHealthReport
{
public List<RegionHealthReport> Regions { get; set; }
public bool OverallHealthy { get; set; }
public int HealthyRegionCount { get; set; }
public int TotalRegionCount { get; set; }
}
Set up dashboards that provide a single-pane-of-glass view of all regions. Key dashboards include: (1) a global overview showing the health status of all regions at a glance, (2) a replication dashboard showing lag between every pair of regions, (3) a latency dashboard showing read and write latencies per region, and (4) a compliance dashboard showing data access patterns and residency compliance. Invest in these dashboards early — they pay for themselves during every incident and every capacity planning exercise.
16. Cost Optimization
Multi-region databases are expensive. Running database instances in three or more regions, replicating data across continents, and maintaining compliance infrastructure all have significant costs. Understanding and optimizing these costs is essential for a sustainable architecture. The three largest cost drivers are compute (database instances), storage (data replicated across regions), and network (cross-region data transfer).
Compute costs can be optimized by right-sizing instances per region. Not all regions need identical configurations — a region with 10% of your traffic may need significantly less compute than your primary region. Use auto-scaling to handle traffic spikes without over-provisioning. Consider read replicas in secondary regions that are smaller than the primary — they serve read traffic efficiently without the full cost of a primary-capable instance.
Storage costs scale linearly with the number of regions and the size of your dataset. If you replicate a 1TB database to three regions, you are paying for 3TB of storage (plus replicas within each region). Storage tiering can reduce costs: move infrequently accessed data to cold storage tiers, and compress data that is replicated but rarely queried. Some databases (e.g., CockroachDB) support zone-level storage configuration, allowing you to use cheaper storage for cold data.
Network costs are often the most surprising line item. AWS charges $0.02/GB for data transfer between regions, and replication traffic can be substantial. A 100GB database with a 10% daily write rate generates 10GB of replication traffic per day per target region. With three regions, that is 30GB/day of cross-region transfer, or about 900GB/month — roughly $18/month for one direction alone. At larger scales, with more data and more regions, network costs can easily exceed compute costs. Reducing cross-region traffic through compression, delta-only replication, and selective replication (only replicate data that needs to be available in all regions) can significantly reduce costs.
| Cost Driver | Typical % of Total | Optimization Strategy | Potential Savings |
|---|---|---|---|
| Compute (DB instances) | 40-50% | Right-sizing, auto-scaling, read replicas | 20-40% |
| Storage | 20-30% | Tiering, compression, selective replication | 15-30% |
| Network (cross-region transfer) | 15-25% | Compression, delta replication, batching | 20-50% |
| Compliance & Audit | 5-10% | Automated auditing, efficient logging | 10-20% |
C# example of a cost-aware replication manager that optimizes bandwidth by only replicating changed data:
C#
public class CostAwareReplicationManager
{
private readonly IReplicationChannel _channel;
private readonly ICompressionService _compression;
private readonly CostBudget _budget;
private long _bytesTransferredThisMonth;
public CostAwareReplicationManager(
IReplicationChannel channel,
ICompressionService compression,
CostBudget budget)
{
_channel = channel;
_compression = compression;
_budget = budget;
}
public async Task<ReplicationResult> ReplicateAsync(
ReplicationBatch batch)
{
// Calculate cost before sending
var rawSize = batch.EstimateSizeBytes();
var compressedSize = await _compression
.EstimateCompressedSizeAsync(batch);
var estimatedCost = CalculateTransferCost(
compressedSize, batch.TargetRegion);
if (_bytesTransferredThisMonth + compressedSize
> _budget.MonthlyTransferLimitBytes)
{
// Budget exceeded — defer or compress more aggressively
return new ReplicationResult
{
Deferred = true,
Reason = "Monthly transfer budget exceeded"
};
}
// Compress and send
var compressed = await _compression
.CompressAsync(batch);
await _channel.SendAsync(compressed);
_bytesTransferredThisMonth += compressedSize;
return new ReplicationResult
{
Deferred = false,
BytesTransferred = compressedSize,
EstimatedCost = estimatedCost
};
}
private decimal CalculateTransferCost(
long bytes, string targetRegion)
{
var gb = bytes / (1024.0 * 1024.0 * 1024.0);
// AWS pricing: $0.02/GB for cross-region
return (decimal)(gb * 0.02);
}
}
public class CostBudget
{
public long MonthlyTransferLimitBytes { get; set; }
public decimal MonthlyTransferBudgetUsd { get; set; }
}
Cost optimization in multi-region systems is an ongoing activity, not a one-time exercise. Set up cost monitoring that tracks expenses per region, per cost driver, and per optimization strategy. Review costs monthly and adjust configurations as your traffic patterns and data volumes evolve. A 20% cost reduction achieved through thoughtful optimization can fund an entire additional region, directly improving your availability and disaster recovery posture.
17. Interview Q&A
The following questions and answers cover the most common multi-region database design topics asked in senior and staff-level system design interviews. Each answer is structured to demonstrate depth of understanding while remaining concise enough for a 30-minute interview segment.
Q1: How would you design a globally distributed user profile system that requires read-after-write consistency?
Answer: I would use a leader-follower topology where each user's profile has a designated leader region (based on their home region or current location). All writes go to the leader, and reads use session stickiness to ensure that a user's session always hits their leader region. For users who travel between regions, I would implement read-your-writes tracking: the session records the timestamp of the last write, and if a read from a local follower would return stale data, it is forwarded to the leader. This provides strong read-after-write consistency without requiring synchronous replication. The partitioning strategy would be user-based, with each user's profile pinned to a single leader region.
Q2: What happens to your multi-region database when a region completely loses network connectivity? How do you handle split-brain?
Answer: When a region loses connectivity, it can no longer replicate with other regions. The remaining healthy regions continue to operate, electing a new leader if the disconnected region was the leader. The isolated region enters a degraded state — it can accept writes but cannot guarantee they will be consistent with other regions. To prevent split-brain, I use fencing tokens: every leader lease includes a monotonically increasing token, and when a former leader reconnects, it must present its token to the other regions. If a newer leader has been elected with a higher token, the former leader's writes are rejected. The isolated region serves read-only traffic or rejects all traffic, depending on the consistency requirements.
Q3: Explain the difference between synchronous and asynchronous replication in a multi-region context. When would you choose each?
Answer: Synchronous replication requires a write to be confirmed by at least one remote region before the client receives a success response. This guarantees zero data loss (zero RPO) but adds the cross-region round-trip time to every write. Asynchronous replication sends the write to followers in the background, allowing the client to receive a response immediately. This provides lower latency but introduces a window where data could be lost if the leader fails. I would choose synchronous replication for critical financial data where zero data loss is non-negotiable, and asynchronous replication for everything else. Within a single region, I always use synchronous replication (the latency cost is minimal). Across regions, I default to async and add sync only where required.
Q4: How would you handle a schema migration across five regions without downtime?
Answer: I would use the expand-and-contract pattern coordinated across regions. First, I apply the expand migration (add new columns/tables) to all followers, then to the leader — this ensures that replication is never broken because the leader's schema is always a superset of followers'. After all regions have the new schema, I deploy application code that is compatible with both schemas. After confirming all regions are running the new code, I apply the contract migration (remove old columns) starting from the leader. Each migration step is idempotent and logged. I would use a migration orchestrator that tracks the schema version in each region and ensures correct ordering. The entire process might take a week for a large schema change, but it guarantees zero downtime.
Q5: Design a multi-region database for a banking application where transfers between accounts in different regions must be atomic.
Answer: I would use the Saga pattern with compensation. A transfer from a US account to a European account would be decomposed into three steps: (1) Debit the US account (local transaction in US-East), (2) Credit the European account (local transaction in EU-West), (3) Record the transfer in a global ledger. If step 2 fails, the compensating action credits the US account back. For strict atomicity guarantees, I would use 2PC across the two regions, but this adds 80+ ms to every transfer. The Saga approach is more practical: the intermediate state (money debited but not yet credited) is visible briefly, but the compensation ensures eventual consistency. For a banking application, I would also implement an idempotency layer to prevent duplicate transfers and an outbox pattern to ensure reliable event publication.
Q6: How do CRDTs work, and when would you use them in a multi-region system?
Answer: CRDTs (Conflict-Free Replicated Data Types) are data structures designed so that concurrent updates from different replicas can be merged automatically without conflicts, guaranteeing convergence. For example, a G-Counter maintains a counter per node; incrementing updates only the local node's counter, and merging takes the max of each node's counter. This guarantees that concurrent increments from different regions are all counted correctly. I would use CRDTs for distributed counters (like view counts, likes), sets (like shopping carts with add/remove), and flags (like read/unread status). I would NOT use CRDTs for complex entities like user profiles or order records, where the merge semantics are too complex or the data structure does not fit the CRDT model. In those cases, application-level conflict resolution or leader-based writes are more appropriate.
Q7: How do you handle compliance with GDPR when designing a multi-region database?
Answer: GDPR compliance requires three key technical measures in a multi-region database: (1) Data residency — EU user data is partitioned to EU regions and is not replicated to non-EU regions. I implement this through tenant-based or user-based partitioning with region pinning. (2) Right to be forgotten — when a user requests deletion, I must delete their data from all regions. I implement this via a global deletion event that is propagated to all regions, with confirmation tracking to ensure deletion is complete. (3) Audit logging — all access to personal data is logged with user identity, timestamp, and purpose. The audit logs themselves must be immutable and retained per regulatory requirements. I also implement row-level security in the database to prevent accidental cross-border data access.
Q8: Compare GeoDNS, Anycast, and client-side routing for global load balancing. When would you use each?
Answer: GeoDNS is the simplest — it resolves domain names to different IPs based on client geography. It is good for coarse-grained routing and is easy to set up, but failover is slow (limited by DNS TTL, typically 5+ minutes). Anycast advertises the same IP from multiple data centers, and BGP routes traffic to the nearest one. It provides instant failover (BGP convergence in seconds) but operates at the network layer and does not consider application-level metrics. Client-side routing makes per-request routing decisions in the application, allowing the most granular and responsive routing based on real-time latency measurements and health checks. I would use a layered approach: GeoDNS or Anycast for coarse global routing (sending traffic to the right continent), and client-side routing for fine-grained optimization (selecting the best region within a continent). This provides both fast failover and optimal latency.
Q9: What is the impact of CAP theorem on your multi-region design decisions?
Answer: The CAP theorem forces a choice between consistency and availability during network partitions. In a multi-region system, partitions are inevitable, so I must make this choice explicitly for each access pattern. For financial transactions and account balances, I choose consistency — reads go to the leader, and the system is unavailable during a partition. For social media feeds and analytics, I choose availability — reads are served from local replicas even if they are stale. This is not a binary decision at the system level; it is a per-query decision. Most modern databases support tunable consistency, allowing me to set the consistency level per query. The PACELC framework extends this to normal operation: even without partitions, I must balance latency and consistency. A well-designed multi-region system is neither purely CP nor purely AP — it is a hybrid that makes deliberate, context-aware trade-offs.
Q10: Walk through your approach to disaster recovery testing for a multi-region database.
Answer: DR testing should be regular, automated, and comprehensive. I would structure it in layers: (1) Weekly automated tests that simulate individual node failures within a region and verify that failover within the region works. (2) Monthly tabletop exercises where the team walks through a full region failure scenario, documenting the steps to failover and recovery. (3) Quarterly chaos engineering drills (using tools like Gremlin or Litmus) that actually kill a region's database and verify that traffic is rerouted, RPO and RTO are within targets, and the recovery process works. (4) Annual full-scale DR tests that simulate a region going down for an extended period, testing both failover and recovery. Each test produces a report with measured RPO, RTO, and any issues found. Action items from tests are prioritized and resolved before the next test. The key metric is not whether failover works, but whether it works within the target RTO — a failover that takes 30 minutes when the target is 5 minutes is a failure, even if it eventually succeeds.