Multi-region active-active is requested as an availability feature and delivered as a consistency problem. The request is usually 'survive a region failure' or 'serve European users from Europe'. Both are reasonable. Neither necessarily requires accepting writes in two places at once, which is the part that changes your data model rather than your deployment.
The physics is unavoidable: a round trip between continents is well over a hundred milliseconds. Any design that requires cross-region coordination on the write path pays that on every write, and loses availability in both regions when the link degrades — which is the opposite of the reason you built it.
Three honest options
Active-passive with asynchronous replication is the one most teams should choose. Writes go to one region, replicas serve reads locally, and failover promotes a standby. You accept a recovery point objective of seconds and a failover procedure that must be rehearsed. In exchange you keep a single writer and therefore your existing consistency model, untouched.
Partitioned active-active is the version that genuinely works at scale: both regions accept writes, but for disjoint sets of data. Tenants, users or accounts are homed to a region, and every write for that entity goes to its home. There is no conflict because there is no concurrent write to the same row — only a routing rule and a migration procedure for moving an entity between homes. Most successful global systems I have seen are this, described loosely as active-active.
True active-active on the same data requires either synchronous consensus across regions — correct, and slow, and jointly unavailable during a partition — or conflict resolution, which means your application must define what happens when two regions change the same thing concurrently. That is a domain decision, not an infrastructure setting, and it cannot be delegated to the database.
# Partitioned active-active: the routing rule IS the consistency model.
HOME = {'eu': 'eu-west-1', 'us': 'us-east-1', 'apac': 'ap-southeast-1'}
def write_endpoint(tenant):
home = tenant_home(tenant) # stored with the tenant, rarely changes
if home != CURRENT_REGION:
# Forward rather than write locally. A local write here is the bug
# that produces the conflict you designed the system to avoid.
return forward_to(HOME[home])
return local_writer
def read_endpoint(tenant, consistency='eventual'):
if consistency == 'strong':
return write_endpoint(tenant) # read from the home region
return local_replica # fast, possibly seconds staleThat explicit read consistency parameter is worth having from day one. Most reads tolerate a few seconds of staleness; a small number — read-after-write in a form the user just submitted, balance checks before a charge — do not. Making it a named argument turns an invisible source of confusing bugs into a documented decision at each call site.
The costs nobody budgets for
- Cross-region data transfer is a real line item, and replication traffic is continuous.
- Schema migrations must be compatible across regions running different versions during a rollout.
- Every cache, queue, search index and object store needs its own regional story. The database is the part people plan; the rest is the part that breaks.
- Failover that is not rehearsed does not work. Test it on a schedule, in production, with traffic.
- Observability must be region-aware or you will debug a regional incident on a global dashboard that averages it away.
Active-active is not a deployment topology. It is a promise about what happens when two regions disagree, and somebody has to write that promise down.
Start from the requirement rather than the architecture. Regulatory data residency is solved by partitioning. Read latency is solved by regional replicas. Surviving a region loss is solved by active-passive with a rehearsed failover. Only 'we must accept writes for the same entity in two regions simultaneously' requires the expensive version — and in ten years of asking, I have found that requirement to be real perhaps twice.