Multi-region and active-active
Serving users from several regions. DR tiers with RPO and RTO, active-passive, home regions, multi-leader, global databases, failover and headroom math.
- Read first
- ReplicationCAP and PACELC
Running in more than one region buys three things:
- Users far from your first region get lower latency.
- The service survives losing a whole region.
- Data can stay in the country or bloc where a law or a contract requires it.
The cost is a second or third copy of everything, plus cross-region transfer. It adds routing, failover and drills. And it forces one hard question: where does each write go, and what happens to it when its region dies?
This page covers the pattern: the topologies, the numbers that decide between them, and how failover goes wrong. The mechanisms have their own pages:
- Replication for lag and conflicts.
- CAP and PACELC for what each topology can promise.
- Consensus for majorities.
- Load balancing and CDN for how a request reaches a region.
The problem it solves
Section titled “The problem it solves”A shop runs in us-east, in three availability zones. It promises 99.99% availability, which is 52 minutes of downtime a year. A third of its users are in Europe and a fifth in Asia and Australia. Three things go wrong at once.
- Latency. Sydney is about 200 ms from Virginia per round trip. A checkout page that makes four sequential calls after the TCP and TLS handshakes spends over a second on the network.
- Regional failure. Zones protect against a fire or power loss in one building. They don’t protect against a bad deploy of a regional control plane, or a storage service failing across the region. Outages of several hours have hit a region at every large cloud provider. One four hour outage spends four and a half years of a 99.99% budget.
- Data residency. A German bank customer’s contract says its data stays in the EU. India’s central bank requires payment system data to be stored only in India. A single US region cannot meet either.
Adding regions answers all three, at a price:
| Cost | What it looks like |
|---|---|
| Hardware | 1.3 to 2 times the servers: every region carries spare capacity to absorb another’s users (see the headroom math) |
| Cross-region transfer | About $0.02 per GB between AWS US and EU regions, more for other pairs. 10 MB/s of replication is 26 TB and about $520 a month per destination, before the second copy of any server |
| Write latency or conflicts | A write that must survive a region loss waits 10 to 230 ms for another region to confirm. One that does not wait can be lost or can conflict |
| Complexity | Routing by user, failover, fencing, reconciliation, drills, and every dependency (auth, config, secrets) run once per region |
Three zones in one region handle machine and building failures at 1 to 2 ms between zones, and many 99.95% services stop there. The real question is how much of the system runs in each region, and how far behind each copy may be.
RPO and RTO
Section titled “RPO and RTO”- RPO, recovery point objective: how much recent data you may lose, measured in time. RPO of 1 second at 3,000 writes a second means up to 3,000 acknowledged writes can vanish.
- RTO, recovery time objective: how long users are down, from failure to service restored.
RPO is set by how replication works: asynchronous replication loses its lag, synchronous or quorum replication across regions loses nothing. RTO is set by how failover works: detection, the decision, promotion, warming up capacity and moving traffic. Every topology below is a point on both dials.
How it works
Section titled “How it works”Step 1: One region, with a way back
Section titled “Step 1: One region, with a way back”The cheapest multi-region design serves everything from one region and keeps enough in a second region to rebuild. The AWS disaster recovery guidance names four strategies, a useful vocabulary on any cloud:
| Strategy | What runs in the second region | RPO | RTO | Cost over one region |
|---|---|---|---|---|
| Backup and restore | Nothing. Snapshots and archived logs sit in object storage | Minutes (log archive) to 24 h (nightly) | Hours | A few percent |
| Pilot light | Databases, replicating. App servers defined but not running | Seconds (replication lag) | Tens of minutes | 10 to 20% |
| Warm standby | A scaled-down copy of everything, live, taking no user traffic | Seconds | Minutes | 25 to 50% |
| Multi-site active-active | A full copy, serving its share of users all the time | Near zero to seconds | Near zero | 100% or more |
Between the last two sits a hot standby: a full-size copy that takes no traffic until failover
(step 2). The restore time in the first row is simple arithmetic. A 2 TB database copied back at
200 MB/s takes about 3 hours. Then the archived log replays on top, and then caches start cold.
Backups are still the only tier that protects against a bug or an operator that deletes data.
Replication copies a DELETE as faithfully as an INSERT.
Step 2: Active-passive, a hot standby region
Section titled “Step 2: Active-passive, a hot standby region”Active-passive keeps a full copy of the stack in region B. Region A takes all writes, and usually all traffic. The database ships its log to B asynchronously, so writes stay at in-region latency. When A fails, failover is five steps, and the RTO (recovery time) is their sum:
- Color key
- Service
- Database
| Step | Typical time | What sets it |
|---|---|---|
| Detect | 30 to 90 s | Probe interval times failures required; probes from outside A |
| Decide | 0 automatically; 5 to 20 min with a human | Paging, joining a call, confirming it is really A and not the probes |
| Fence and promote | 10 to 60 s | Revoke A’s right to write, then let B’s database take writes |
| Warm up | 0 for hot standby; 10+ min for warm | Scaling out, filling caches |
| Move traffic | The DNS TTL (30 to 60 s) plus clients that ignore it | DNS, or seconds with anycast and a global load balancer |
The RPO (how much data you lose) is the lag at the moment A died, times the write rate. Lag is a few hundred milliseconds on a quiet day. It grows under the load spikes that come before many outages. Replication covers promotion and fencing. Across regions, the same rules hold with longer timeouts. A 90 ms link that drops for 20 seconds looks exactly like a dead region.
Step 3: Get each user to a region
Section titled “Step 3: Get each user to a region”Once more than one region serves traffic, something must pick a region for each request. It must also change that pick when a region fails. Networking covers DNS, and the CDN page covers anycast. The choices are:
| Mechanism | How it picks | Failover time | Wins when |
|---|---|---|---|
| GeoDNS / latency-based DNS | The authoritative DNS answers with the region nearest the resolver | Detection plus TTL, with a tail of clients that cache longer | Almost every web API: cheap, works everywhere |
| Anycast | Every region announces the same IP; BGP delivers to the nearest | Seconds to a minute: BGP withdraws the route | DNS, CDNs, edge proxies; UDP and short TCP flows |
| Global load balancer | Anycast front ends near users, which proxy to a healthy region over the provider’s backbone | Seconds, with health checks per backend | HTTP APIs where a TTL tail is not acceptable |
| Client-side choice | The app holds a region list and measures, or the server tells it its home region | As fast as the client notices | Mobile apps and SDKs you control |
Nearest is not always right. Writes may belong to a home region (step 4). Residency may require a particular region (step 7). And the nearest survivor may be full. So anycast or GeoDNS reaches a nearby gateway, and the gateway decides which region owns the request.
Step 4: Active-active with a home region per user
Section titled “Step 4: Active-active with a home region per user”Active-active means every region serves users all the time. The question is which region accepts which writes. Most products land on one answer, and the problem pages on this site keep choosing it: every user, tenant or document has one home region. It sits near most of its activity, and only that region accepts its writes. Other regions hold asynchronous copies, for local reads and failover.
- Color key
- Service
- Database
The gateway needs the home region before it can route. Three ways to know it:
- A directory lookup: user or tenant to home region, replicated to every region and cached in the gateways. Read on every request, written almost never.
- Encoded in the identifier: region bits inside the ID, as the unique ID generator does. No lookup, but moving a user means a new ID or an override table.
- A claim in the session token: signed at login. Moving a user waits for the token to refresh.
Inside one home, it is ordinary single-leader replication: linearizable per user, with no conflicts. Now take a cross-home interaction, like Ana messaging Bob. It writes in Ana’s home, and reaches Bob’s region asynchronously. That’s fine for messages, follows and likes. It’s wrong for anything that must be atomic across both. Those need one owner (both rows in one home), or a distributed transaction across regions.
Failure is contained. When eu-west dies, only EU-homed users lose writes. That lasts until their data is promoted where its copy lives. With three regions, that’s a third of users.
The directory below maps homes to owners, with an epoch. It fails a home over to the next region its residency rules allow. Each copy refuses writes from an older epoch. So an old owner that comes back cannot overwrite the new one:
// Region-aware routing: every user has a home region, writes go to the region that currently owns// that home, failover moves ownership to the next allowed region with a higher epoch, and copies of// the data refuse writes stamped with an older epoch.import java.util.*;
public class RegionRouter { static final List<String> REGIONS = List.of("us-east", "us-west", "eu-west", "eu-central");
// Failover order per home region, nearest first. Planned in advance, not chosen during an outage. static final Map<String, List<String>> FAILOVER = Map.of( "us-east", List.of("us-west", "eu-west"), "us-west", List.of("us-east", "eu-west"), "eu-west", List.of("eu-central", "us-east"), "eu-central", List.of("eu-west", "us-east"));
// Residency narrows the failover list: data homed in the EU may only be served from the EU. static final Map<String, Set<String>> ALLOWED = Map.of( "EU", Set.of("eu-west", "eu-central"), "ANY", Set.copyOf(REGIONS)); static final Map<String, String> RESIDENCY = Map.of( "us-east", "ANY", "us-west", "ANY", "eu-west", "EU", "eu-central", "EU");
// One region's copy of one home region's data. It remembers the highest epoch it has seen and // refuses anything older, so a deposed owner cannot overwrite the new owner's data. static class Copy { int epoch = 0; final Map<String, String> rows = new HashMap<>();
boolean write(int epoch, String key, String value) { if (epoch < this.epoch) return false; // fenced: a newer owner exists this.epoch = epoch; rows.put(key, value); return true; } }
record Owner(String region, int epoch) {}
// The control plane: who owns writes for each home region, and at which epoch. In production it // lives in a store replicated by consensus across three regions, so a region that is cut off // can neither change it nor pretend it changed. static class Directory { final Set<String> healthy = new HashSet<>(REGIONS); final Map<String, Owner> owner = new LinkedHashMap<>(); // insertion order: a stable log // Every region that may own a home keeps a copy of its data (replicated asynchronously). final Map<String, Map<String, Copy>> copies = new HashMap<>();
Directory() { for (String home : REGIONS) { owner.put(home, new Owner(home, 1)); Map<String, Copy> c = new HashMap<>(); c.put(home, new Copy()); for (String r : FAILOVER.get(home)) c.put(r, new Copy()); copies.put(home, c); } }
void failOver(String dead) { healthy.remove(dead); for (String home : REGIONS) { Owner o = owner.get(home); if (!o.region().equals(dead)) continue; Set<String> allowed = ALLOWED.get(RESIDENCY.get(home)); Optional<String> target = FAILOVER.get(home).stream() .filter(r -> healthy.contains(r) && allowed.contains(r)) .findFirst(); if (target.isEmpty()) { // Pausing writes keeps the residency promise; // serving EU data from the US would break it. System.out.println(" " + home + ": no allowed region left, writes pause"); continue; } // The new owner claims the copy with a higher epoch before taking any write. owner.put(home, new Owner(target.get(), o.epoch() + 1)); copies.get(home).get(target.get()).epoch = o.epoch() + 1; System.out.println(" " + home + ": owner " + dead + " -> " + target.get() + ", epoch " + (o.epoch() + 1)); } } }
// Runs in every region's gateway. Looks up the user's home, then the home's current owner. static class Router { final Directory dir; final Map<String, String> homes; // user -> home region, chosen at sign-up
Router(Directory dir, Map<String, String> homes) { this.dir = dir; this.homes = homes; }
String write(String user, String key, String value) { String home = homes.get(user); Owner o = dir.owner.get(home); if (!dir.healthy.contains(o.region())) return user + ": unavailable (home " + home + " has no owner)"; boolean ok = dir.copies.get(home).get(o.region()).write(o.epoch(), key, value); return user + ": " + (ok ? "ok" : "fenced") + " via " + o.region() + " at epoch " + o.epoch(); } }
public static void main(String[] args) { Directory d = new Directory(); Router router = new Router(d, Map.of("ana", "eu-west", "bob", "us-east")); System.out.println(router.write("ana", "cart", "book")); System.out.println(router.write("bob", "cart", "lamp"));
System.out.println("eu-west stops answering health checks"); d.failOver("eu-west"); System.out.println(router.write("ana", "cart", "book, pen"));
// eu-west was cut off, not dead. Its old primary still thinks it owns the EU-west users and // ships a write it accepted at epoch 1. The new owner's copy has seen epoch 2 and refuses it. boolean shipped = d.copies.get("eu-west").get("eu-central").write(1, "cart", "book, mug"); System.out.println("old eu-west primary replicates at epoch 1: " + (shipped ? "accepted" : "fenced"));
System.out.println("eu-central fails too"); d.failOver("eu-central"); System.out.println(router.write("ana", "cart", "book, pen, ink")); System.out.println(router.write("bob", "cart", "lamp, rug")); }}# Region-aware routing: every user has a home region, writes go to the region that currently owns# that home, failover moves ownership to the next allowed region with a higher epoch, and copies of# the data refuse writes stamped with an older epoch.
REGIONS = ["us-east", "us-west", "eu-west", "eu-central"]
# Failover order per home region, nearest first. Planned in advance, not chosen during an outage.FAILOVER = { "us-east": ["us-west", "eu-west"], "us-west": ["us-east", "eu-west"], "eu-west": ["eu-central", "us-east"], "eu-central": ["eu-west", "us-east"],}
# Residency narrows the failover list: data homed in the EU may only be served from the EU.ALLOWED = {"EU": {"eu-west", "eu-central"}, "ANY": set(REGIONS)}RESIDENCY = {"us-east": "ANY", "us-west": "ANY", "eu-west": "EU", "eu-central": "EU"}
# One region's copy of one home region's data. It remembers the highest epoch it has seen# and refuses anything older, so a deposed owner cannot overwrite the new owner's data.class Copy: def __init__(self): self.epoch = 0 self.rows = {}
def write(self, epoch, key, value): if epoch < self.epoch: return False # fenced: a newer owner exists self.epoch = epoch self.rows[key] = value return True
# The control plane: who owns writes for each home region, and at which epoch. In production# it lives in a store replicated by consensus across three regions, so a region that is cut off# can neither change it nor pretend it changed.class Directory: def __init__(self): self.healthy = set(REGIONS) self.owner = {home: (home, 1) for home in REGIONS} # home -> (owning region, epoch) # Every region that may own a home keeps a copy of its data (replicated asynchronously). self.copies = {home: {r: Copy() for r in [home] + FAILOVER[home]} for home in REGIONS}
def fail_over(self, dead): self.healthy.discard(dead) for home, (owner, epoch) in self.owner.items(): if owner != dead: continue allowed = ALLOWED[RESIDENCY[home]] target = next( (r for r in FAILOVER[home] if r in self.healthy and r in allowed), None ) if target is None: # Pausing writes keeps the residency promise; # serving EU data from the US would break it. print(f" {home}: no allowed region left, writes pause") continue # The new owner claims the copy with a higher epoch before taking any write. self.owner[home] = (target, epoch + 1) self.copies[home][target].epoch = epoch + 1 print(f" {home}: owner {dead} -> {target}, epoch {epoch + 1}")
# Runs in every region's gateway. Looks up the user's home, then the home's current owner.class Router: def __init__(self, directory, homes): self.dir = directory self.homes = homes # user -> home region, chosen at sign-up from location and residency
def write(self, user, key, value): home = self.homes[user] owner, epoch = self.dir.owner[home] if owner not in self.dir.healthy: return f"{user}: unavailable (home {home} has no owner)" ok = self.dir.copies[home][owner].write(epoch, key, value) return f"{user}: {'ok' if ok else 'fenced'} via {owner} at epoch {epoch}"
if __name__ == "__main__": d = Directory() router = Router(d, {"ana": "eu-west", "bob": "us-east"}) print(router.write("ana", "cart", "book")) print(router.write("bob", "cart", "lamp"))
print("eu-west stops answering health checks") d.fail_over("eu-west") print(router.write("ana", "cart", "book, pen"))
# eu-west was cut off, not dead. Its old primary still thinks it owns the EU-west users and # ships a write it accepted at epoch 1. The new owner's copy has seen epoch 2 and refuses it. shipped = d.copies["eu-west"]["eu-central"].write(1, "cart", "book, mug") print(f"old eu-west primary replicates at epoch 1: {'accepted' if shipped else 'fenced'}")
print("eu-central fails too") d.fail_over("eu-central") print(router.write("ana", "cart", "book, pen, ink")) print(router.write("bob", "cart", "lamp, rug"))using System;using System.Collections.Generic;using System.Linq;
// Region-aware routing: every user has a home region, writes go to the region that currently owns// that home, failover moves ownership to the next allowed region with a higher epoch, and copies of// the data refuse writes stamped with an older epoch.
// One region's copy of one home region's data. It remembers the highest epoch it has seen and// refuses anything older, so a deposed owner cannot overwrite the new owner's data.public sealed class RegionCopy{ public int Epoch { get; set; } public Dictionary<string, string> Rows { get; } = new();
public bool Write(int epoch, string key, string value) { if (epoch < Epoch) return false; // fenced: a newer owner exists Epoch = epoch; Rows[key] = value; return true; }}
public readonly record struct Owner(string Region, int Epoch);
// The control plane: who owns writes for each home region, and at which epoch. In production it// lives in a store replicated by consensus across three regions, so a region that is cut off can// neither change it nor pretend it changed.public sealed class RegionDirectory{ public static readonly string[] Regions = { "us-east", "us-west", "eu-west", "eu-central" };
// Failover order per home region, nearest first. Planned in advance, not chosen during an outage. public static readonly Dictionary<string, string[]> Failover = new() { ["us-east"] = new[] { "us-west", "eu-west" }, ["us-west"] = new[] { "us-east", "eu-west" }, ["eu-west"] = new[] { "eu-central", "us-east" }, ["eu-central"] = new[] { "eu-west", "us-east" }, };
// Residency narrows the failover list: data homed in the EU may only be served from the EU. static readonly Dictionary<string, HashSet<string>> Allowed = new() { ["EU"] = new HashSet<string> { "eu-west", "eu-central" }, ["ANY"] = new HashSet<string>(Regions), }; static readonly Dictionary<string, string> Residency = new() { ["us-east"] = "ANY", ["us-west"] = "ANY", ["eu-west"] = "EU", ["eu-central"] = "EU", };
public HashSet<string> Healthy { get; } = new(Regions); public Dictionary<string, Owner> Owners { get; } = new(); // Every region that may own a home keeps a copy of its data (replicated asynchronously). public Dictionary<string, Dictionary<string, RegionCopy>> Copies { get; } = new();
public RegionDirectory() { foreach (var home in Regions) { Owners[home] = new Owner(home, 1); Copies[home] = new[] { home }.Concat(Failover[home]).ToDictionary(r => r, _ => new RegionCopy()); } }
public void FailOver(string dead) { Healthy.Remove(dead); foreach (var home in Regions) // a fixed order, so the log reads the same every run { var o = Owners[home]; if (o.Region != dead) continue; var allowed = Allowed[Residency[home]]; var target = Failover[home].FirstOrDefault(r => Healthy.Contains(r) && allowed.Contains(r)); if (target is null) { // Pausing writes keeps the residency promise; // serving EU data from the US would break it. Console.WriteLine($" {home}: no allowed region left, writes pause"); continue; } // The new owner claims the copy with a higher epoch before taking any write. Owners[home] = new Owner(target, o.Epoch + 1); Copies[home][target].Epoch = o.Epoch + 1; Console.WriteLine($" {home}: owner {dead} -> {target}, epoch {o.Epoch + 1}"); } }}
// Runs in every region's gateway. Looks up the user's home, then the home's current owner.public sealed class RegionRouter{ readonly RegionDirectory _dir; readonly Dictionary<string, string> _homes; // user -> home region, chosen at sign-up
public RegionRouter(RegionDirectory dir, Dictionary<string, string> homes) { _dir = dir; _homes = homes; }
public string Write(string user, string key, string value) { var home = _homes[user]; var o = _dir.Owners[home]; if (!_dir.Healthy.Contains(o.Region)) return $"{user}: unavailable (home {home} has no owner)"; var ok = _dir.Copies[home][o.Region].Write(o.Epoch, key, value); return $"{user}: {(ok ? "ok" : "fenced")} via {o.Region} at epoch {o.Epoch}"; }}
public static class Program{ public static void Main() { var d = new RegionDirectory(); var router = new RegionRouter(d, new() { ["ana"] = "eu-west", ["bob"] = "us-east" }); Console.WriteLine(router.Write("ana", "cart", "book")); Console.WriteLine(router.Write("bob", "cart", "lamp"));
Console.WriteLine("eu-west stops answering health checks"); d.FailOver("eu-west"); Console.WriteLine(router.Write("ana", "cart", "book, pen"));
// eu-west was cut off, not dead. Its old primary still thinks it owns the EU-west users and // ships a write it accepted at epoch 1. The new owner's copy has seen epoch 2 and refuses it. var shipped = d.Copies["eu-west"]["eu-central"].Write(1, "cart", "book, mug"); Console.WriteLine($"old eu-west primary replicates at epoch 1: {(shipped ? "accepted" : "fenced")}");
Console.WriteLine("eu-central fails too"); d.FailOver("eu-central"); Console.WriteLine(router.Write("ana", "cart", "book, pen, ink")); Console.WriteLine(router.Write("bob", "cart", "lamp, rug")); }}// Region-aware routing: every user has a home region, writes go to the region that currently owns// that home, failover moves ownership to the next allowed region with a higher epoch, and copies of// the data refuse writes stamped with an older epoch.package main
import ( "fmt" "slices")
var regions = []string{"us-east", "us-west", "eu-west", "eu-central"}
// Failover order per home region, nearest first. Planned in advance, not chosen during an outage.var failover = map[string][]string{ "us-east": {"us-west", "eu-west"}, "us-west": {"us-east", "eu-west"}, "eu-west": {"eu-central", "us-east"}, "eu-central": {"eu-west", "us-east"},}
// Residency narrows the failover list: data homed in the EU may only be served from the EU.var allowed = map[string][]string{ "EU": {"eu-west", "eu-central"}, "ANY": regions,}var residency = map[string]string{ "us-east": "ANY", "us-west": "ANY", "eu-west": "EU", "eu-central": "EU",}
// Copy is one region's copy of one home region's data. It remembers the highest epoch it has seen// and refuses anything older, so a deposed owner cannot overwrite the new owner's data.type Copy struct { epoch int rows map[string]string}
func (c *Copy) Write(epoch int, key, value string) bool { if epoch < c.epoch { return false // fenced: a newer owner exists } c.epoch = epoch c.rows[key] = value return true}
type Owner struct { Region string Epoch int}
// Directory is the control plane: who owns writes for each home region, and at which epoch. In// production it lives in a store replicated by consensus across three regions, so a region that is// cut off can neither change it nor pretend it changed.type Directory struct { healthy map[string]bool owner map[string]Owner // Every region that may own a home keeps a copy of its data (replicated asynchronously). copies map[string]map[string]*Copy}
func NewDirectory() *Directory { d := &Directory{healthy: map[string]bool{}, owner: map[string]Owner{}, copies: map[string]map[string]*Copy{}} for _, home := range regions { d.healthy[home] = true d.owner[home] = Owner{home, 1} d.copies[home] = map[string]*Copy{} for _, r := range append([]string{home}, failover[home]...) { d.copies[home][r] = &Copy{rows: map[string]string{}} } } return d}
func (d *Directory) FailOver(dead string) { d.healthy[dead] = false for _, home := range regions { // a fixed order, so the log reads the same every run o := d.owner[home] if o.Region != dead { continue } target := "" for _, r := range failover[home] { if d.healthy[r] && slices.Contains(allowed[residency[home]], r) { target = r break } } if target == "" { // Pausing writes keeps the residency promise; // serving EU data from the US would break it. fmt.Printf(" %s: no allowed region left, writes pause\n", home) continue } // The new owner claims the copy with a higher epoch before taking any write. d.owner[home] = Owner{target, o.Epoch + 1} d.copies[home][target].epoch = o.Epoch + 1 fmt.Printf(" %s: owner %s -> %s, epoch %d\n", home, dead, target, o.Epoch+1) }}
// Router runs in every region's gateway. It looks up the user's home, then the home's owner.type Router struct { dir *Directory homes map[string]string // user -> home region, chosen at sign-up from location and residency}
func (rt *Router) Write(user, key, value string) string { home := rt.homes[user] o := rt.dir.owner[home] if !rt.dir.healthy[o.Region] { return fmt.Sprintf("%s: unavailable (home %s has no owner)", user, home) } result := "fenced" if rt.dir.copies[home][o.Region].Write(o.Epoch, key, value) { result = "ok" } return fmt.Sprintf("%s: %s via %s at epoch %d", user, result, o.Region, o.Epoch)}
func main() { d := NewDirectory() router := &Router{d, map[string]string{"ana": "eu-west", "bob": "us-east"}} fmt.Println(router.Write("ana", "cart", "book")) fmt.Println(router.Write("bob", "cart", "lamp"))
fmt.Println("eu-west stops answering health checks") d.FailOver("eu-west") fmt.Println(router.Write("ana", "cart", "book, pen"))
// eu-west was cut off, not dead. Its old primary still thinks it owns the EU-west users and // ships a write it accepted at epoch 1. The new owner's copy has seen epoch 2 and refuses it. shipped := d.copies["eu-west"]["eu-central"].Write(1, "cart", "book, mug") verdict := "fenced" if shipped { verdict = "accepted" } fmt.Printf("old eu-west primary replicates at epoch 1: %s\n", verdict)
fmt.Println("eu-central fails too") d.FailOver("eu-central") fmt.Println(router.Write("ana", "cart", "book, pen, ink")) fmt.Println(router.Write("bob", "cart", "lamp, rug"))}// Region-aware routing: every user has a home region, writes go to the region that currently owns// that home, failover moves ownership to the next allowed region with a higher epoch, and copies of// the data refuse writes stamped with an older epoch.
const REGIONS = ['us-east', 'us-west', 'eu-west', 'eu-central'];
// Failover order per home region, nearest first. Planned in advance, not chosen during an outage.const FAILOVER: Record<string, string[]> = { 'us-east': ['us-west', 'eu-west'], 'us-west': ['us-east', 'eu-west'], 'eu-west': ['eu-central', 'us-east'], 'eu-central': ['eu-west', 'us-east'],};
// Residency narrows the failover list: data homed in the EU may only be served from the EU.const ALLOWED: Record<string, Set<string>> = { EU: new Set(['eu-west', 'eu-central']), ANY: new Set(REGIONS),};const RESIDENCY: Record<string, string> = { 'us-east': 'ANY', 'us-west': 'ANY', 'eu-west': 'EU', 'eu-central': 'EU',};
// One region's copy of one home region's data. It remembers the highest epoch it has seen and// refuses anything older, so a deposed owner cannot overwrite the new owner's data.class Copy { epoch = 0; rows = new Map<string, string>();
write(epoch: number, key: string, value: string): boolean { if (epoch < this.epoch) return false; // fenced: a newer owner exists this.epoch = epoch; this.rows.set(key, value); return true; }}
// The control plane: who owns writes for each home region, and at which epoch. In production it// lives in a store replicated by consensus across three regions, so a region that is cut off can// neither change it nor pretend it changed.class Directory { healthy = new Set(REGIONS); owner = new Map<string, { region: string; epoch: number }>(); // Every region that may own a home keeps a copy of its data (replicated asynchronously). copies = new Map<string, Map<string, Copy>>();
constructor() { for (const home of REGIONS) { this.owner.set(home, { region: home, epoch: 1 }); this.copies.set(home, new Map([home, ...FAILOVER[home]].map((r) => [r, new Copy()]))); } }
failOver(dead: string): void { this.healthy.delete(dead); for (const [home, { region, epoch }] of this.owner) { if (region !== dead) continue; const allowed = ALLOWED[RESIDENCY[home]]; const target = FAILOVER[home].find((r) => this.healthy.has(r) && allowed.has(r)); if (target === undefined) { // Pausing writes keeps the residency promise; // serving EU data from the US would break it. console.log(` ${home}: no allowed region left, writes pause`); continue; } // The new owner claims the copy with a higher epoch before taking any write. this.owner.set(home, { region: target, epoch: epoch + 1 }); this.copies.get(home)!.get(target)!.epoch = epoch + 1; console.log(` ${home}: owner ${dead} -> ${target}, epoch ${epoch + 1}`); } }}
// Runs in every region's gateway. Looks up the user's home, then the home's current owner.class Router { // homes: user -> home region, chosen at sign-up from location and residency. constructor( private dir: Directory, private homes: Record<string, string>, ) {}
write(user: string, key: string, value: string): string { const home = this.homes[user]; const { region, epoch } = this.dir.owner.get(home)!; if (!this.dir.healthy.has(region)) return `${user}: unavailable (home ${home} has no owner)`; const ok = this.dir.copies.get(home)!.get(region)!.write(epoch, key, value); return `${user}: ${ok ? 'ok' : 'fenced'} via ${region} at epoch ${epoch}`; }}
const d = new Directory();const router = new Router(d, { ana: 'eu-west', bob: 'us-east' });console.log(router.write('ana', 'cart', 'book'));console.log(router.write('bob', 'cart', 'lamp'));
console.log('eu-west stops answering health checks');d.failOver('eu-west');console.log(router.write('ana', 'cart', 'book, pen'));
// eu-west was cut off, not dead. Its old primary still thinks it owns the EU-west users and ships// a write it accepted at epoch 1. The new owner's copy has seen epoch 2 and refuses it.const shipped = d.copies.get('eu-west')!.get('eu-central')!.write(1, 'cart', 'book, mug');console.log(`old eu-west primary replicates at epoch 1: ${shipped ? 'accepted' : 'fenced'}`);
console.log('eu-central fails too');d.failOver('eu-central');console.log(router.write('ana', 'cart', 'book, pen, ink'));console.log(router.write('bob', 'cart', 'lamp, rug'));The failover order is planned ahead, so the decision during an outage is mechanical. The directory bumps the epoch from a consensus store spread over three regions, so a region cut off from the others cannot. When only non-EU regions are left, EU-homed writes pause instead of moving: that is what a residency promise costs in availability.
Step 5: Every region writes everything
Section titled “Step 5: Every region writes everything”Multi-leader active-active lets any region accept any write, and ships changes to the others asynchronously. Every user writes locally. Failover is a routing change, with no promotion. But conflicts become normal: two regions change one record in the same second, and both say “saved”. Replication covers how to resolve them, and consistency models covers what readers then see. What matters here is which data can live this way:
| Data | Multi-leader works? | Because |
|---|---|---|
| Counters, likes, view counts | Yes, as CRDT counters | Increments commute; each region owns its slot |
| Shopping carts, sets of tags, presence | Yes, with set CRDTs or add-wins merges | A merge that keeps both sides is acceptable |
| Profiles, settings | Usually, with last-writer-wins per field | Losing one of two edits in the same second is tolerable |
| Usernames, seats, inventory, balances | No | Two regions each check their own copy and both say yes |
| Anything with an invariant across rows | No | No merge rule can restore it after the fact |
Real systems mix the two. Posts and profiles live in a home region. Counters are CRDTs, written everywhere. Usernames have one global owner. Managed stores offer the multi-leader half directly. DynamoDB global tables (in their default mode) and Cassandra across datacenters accept writes in every region, and reconcile by timestamp. The key-value store does this with LOCAL_QUORUM.
When a region dies under multi-leader, writes it accepted but had not shipped are stranded, not gone. They come back with the region and are merged. Some may conflict with what users did elsewhere in the meantime.
Step 6: Globally consistent databases
Section titled “Step 6: Globally consistent databases”Spanner, CockroachDB and YugabyteDB put the replicas of each range in several regions. They commit through consensus: a write is durable once a majority of replicas has it. Lose one region, and a majority survives with every acknowledged write. RPO is zero, and failover is a leader election of seconds. Nobody writes conflict-resolution code.
The bill comes on every write: a commit waits at least one round trip, from the range’s leader to the nearest region that completes its majority.
| Replica placement | Commit latency from the leader | Survives |
|---|---|---|
| 3 zones in one region | 1 to 3 ms | A zone |
| 3 regions on one continent (Virginia, Ohio, Oregon) | 10 to 50 ms | A region |
| 3 regions on three continents (US, EU, Asia) | 90 to 160 ms | A region |
| Leader’s region holds 2 of 5 replicas, 3 regions total | The nearest other region’s RTT | A region |
The levers that make it usable are where leaders live and which reads skip them:
- Pin each row’s leader to its users’ region. CockroachDB’s
REGIONAL BY ROWtables and Spanner’s leader placement do this. Local reads stay local. If the data must survive a region loss, writes pay one hop to the nearest other region. If surviving a zone loss is enough, they stay local. This is the home-region pattern, with the database doing the failover. - Global tables for read-mostly data. Every region holds a consistent readable copy, and writes pay hundreds of ms for it. Fits currencies, product catalogs, feature flags.
- Stale reads. Reading the nearest replica at a timestamp a few seconds old needs no leader and no cross-region trip. Reads that cannot accept that go to the leader.
Go deeperCells, to limit what one failure reaches1 min
A region is a large blast radius for failures that have nothing to do with geography: a bad deploy, a poison message, a runaway tenant. Cell-based architecture splits the fleet into independent copies of the whole stack, each serving a fixed slice of users behind a thin router. Cells share nothing, so with 8 cells a failure reaches an eighth of users, and deploys roll out one cell at a time. Cells and regions compose: the notification system runs four cells in each of two regions. The cell router is the one shared piece, so it stays small, cached everywhere, and able to route with a stale map.
Step 7: Route by law, not only by distance
Section titled “Step 7: Route by law, not only by distance”Residency adds a constraint to every step above: some users’ data may only live in some regions. Know where the rule comes from. GDPR does not require EU data to stay in the EU. It allows transfers out of the EEA, to countries with an adequacy decision, or under safeguards such as standard contractual clauses. Strict localization comes from customer contracts, sector rules and some national laws, like the Indian payment rule above.
- Home region from residency first, distance second. An EU tenant that promised EU-only storage gets an EU home, even if its users sit nearer to us-east.
- Failover inside the boundary. That tenant’s failover list names only EU regions. With one EU region, an EU outage pauses its writes, rather than moving them. The fix is a second EU region.
- The global directory holds no personal data. It maps an opaque ID to a region. Email lookups, search indexes and analytics that span regions must not copy residents’ records out.
Go deeperResidency beyond the database1 min
Backups, logs, traces, support tools, caches and CDNs hold copies of personal data too. Residency leaks most often through observability pipelines that ship every log to one central region. Each of these needs the same per-region rule as the database.
Try it
Section titled “Try it”Three regions serve six cities, with two homed in each, at 500 writes a second per city. Pick a topology, and press Send writes. Watch where each write goes (blue), what it waits for before the acknowledgment (orange), and what is copied afterwards (green). Then kill a region. Read the write latency, the writes lost, the outage timeline, and the load on the regions left.
Write and its acknowledgmentQuorum round trip (before the ack)Async replication (after the ack)Round trips are approximate public figures, rounded.
- Write latency, mean / worst user
- -
- Writes lost or stranded (RPO)
- -
- Outage (RTO)
- -
- Busiest surviving region
- -
Load on each region after failover
Outage timeline for affected users
| User | Home | Writes go to | Write latency |
|---|
Things to try:
- Active-passive, then Active-active, home regions. With every write going to us-east the mean write latency is 136 ms and Sydney pays 203 ms. Home regions bring the mean to 84 ms and the worst to 118 ms, from the same three regions.
- Kill us-east under Active-passive. 3,000 acknowledged writes are gone: one second of lag at 3,000 writes a second. Every city is down for 2.5 minutes, or 17.5 if a human decides. With automation and Warm standby, the outage is 13 minutes, and eu-central runs at 240% until it scales up. With Backup and restore: 900,000 writes and 3.6 hours.
- Kill us-east under home regions. Only Los Angeles and São Paulo are affected, and 1,000 writes are lost. GeoDNS sends both cities to eu-central, now at 120%. With Both survivors each takes one city and sits at 90%: the 1.5 times of the headroom rule.
- Global consensus. Madrid’s writes cost 123 ms instead of 33, because each waits for us-east. Kill a region: nothing acknowledged is lost and the displaced cities are back in about a minute.
- Multi-leader, kill eu-central. Failover needs no promotion, but 1,000 writes are stranded in eu-central until it returns.
Options and tradeoffs
Section titled “Options and tradeoffs”Topology
Section titled “Topology”| Topology | Write latency | RPO on a region loss | RTO | Conflicts | Cost | Wins when |
|---|---|---|---|---|---|---|
| Backup and restore | One region’s, far users pay | Minutes to hours | Hours | None | Lowest | Internal tools; 99.9% targets; protection against deletion |
| Pilot light or warm standby | One region’s | Seconds | Minutes to tens of minutes | None | +10 to 50% | A region outage can cost half an hour |
| Active-passive, hot | One region’s | Seconds | 1 to 5 min (minutes more with a human) | None | About 2x | Users in one area, 99.99% target, no appetite for conflicts |
| Active-active, home regions | Local for home users | Seconds, failed region’s users only | 1 to 5 min, a third of users | None | N regions + headroom | Global consumer and SaaS products: the usual answer |
| Active-active, multi-leader | Local for everyone | Seconds, stranded not lost | Routing only, about a minute | On every concurrent write | N regions + headroom | Commutative data: counters, carts, presence, caches |
| Global consensus database | Plus a cross-region RTT per write | Zero | Seconds (election) plus routing | None | Highest per write | Money, inventory, uniqueness across regions |
Synchronous or asynchronous across regions
Section titled “Synchronous or asynchronous across regions”Synchronous replication to a region 10 ms away costs 10 ms a commit, and loses nothing. The job scheduler does this. To a region 90 ms away, few user-facing writes accept the cost. The common middle path is synchronous across zones, and asynchronous across regions. Watch the backlog as “data a failover would lose right now”. (Replication has the full table.)
Who decides to fail over
Section titled “Who decides to fail over”| Decision | RTO added | Risk | Wins when |
|---|---|---|---|
| Automatic | Nothing | Fails over on a false alarm or a partition; flaps between regions | Stateless tiers; databases with fencing and RPO zero |
| Human decides, automation acts | 5 to 20 minutes | Slower; depends on the on-call being reachable and calm | Asynchronous databases, where failover loses data |
| Human runs a manual runbook | 30 minutes to hours | Steps skipped or done in the wrong order under stress | Nothing, once a system matters |
Most teams split it. Routing away from a dead region is automatic. But promoting an asynchronous database is a human’s one-click decision. Promotion throws away unreplicated writes. And if the old primary is only cut off, not dead, it risks two primaries. Failovers that lose nothing automate both.
Go deeperFail over through the data plane, not the control plane1 min
DNS providers and cloud load balancers have two halves:
- The data plane answers queries and forwards packets, everywhere, with high availability.
- The control plane is the API that changes records and settings. It often runs in fewer places, sometimes in one region.
A runbook step like “call the API to change the record” depends on the control plane at the worst moment. During a large regional event, everyone else is calling it too. Prefer failover that the data plane does on its own. Use health-checked records that stop answering for a dead region. Or use routing controls set up in advance, flipped with a call designed for exactly this. The same goes for your own dependencies. An auth service, a secrets store or a feature flag service that lives in one region makes every region depend on it.
How it fails
Section titled “How it fails”Split brain after a partition
Section titled “Split brain after a partition”The link between regions A and B drops for two minutes. Probes in B see A as dead, B is promoted, and A, still alive and serving its local users, keeps accepting writes. Two primaries diverge, and the merge afterwards is manual. The defences:
- Color key
- Service
- Database
- Decide with a majority across three places. A third region breaks the tie. It can be a witness, or the consensus store behind the directory. Then the side cut off from two of three cannot promote itself. And the primary that loses the majority stops accepting writes when its lease expires.
- Fence with an epoch. Every promotion bumps a number that every copy checks, so writes from the old primary are refused when it returns. The code above does this.
- Probe from several vantage points. “A is unreachable from B” is not “A is down”.
Failover that moves the servers but not the data
Section titled “Failover that moves the servers but not the data”Region B’s app servers pass their health checks, so DNS sends everyone there. And every write fails. B’s database is still a read-only replica. Or B’s app servers still point at A’s database endpoint, across an ocean. Health checks must test the path a real request takes, including a write. Promote data before moving traffic. And drain a region whose data is not writable.
Replication lag eats acknowledged writes
Section titled “Replication lag eats acknowledged writes”Lag is a few hundred milliseconds on average, and tens of seconds in the bad minutes. Those are often the minutes before an outage. A forced failover drops whatever was in flight. The defenses:
- Alert on lag as live RPO.
- Let clients resubmit with idempotency keys.
- Keep the old primary’s disk, so its unreplicated tail can be reconciled.
- Use a synchronous or consensus store where loss is unacceptable.
The payment system rebuilds the lost window from the payment provider’s records, before the survivor takes payments.
The surviving regions need headroom
Section titled “The surviving regions need headroom”When one of N equal regions fails, its load spreads over the other N - 1. So each survivor carries N / (N - 1) times its normal load. To survive that, each region’s peak utilization must stay below (N - 1) / N:
| Regions | Survivor load after one fails, and hardware vs one region at 100% | Highest safe peak utilization |
|---|---|---|
| 2 | 2x | 50% |
| 3 | 1.5x | 67% |
| 4 | 1.33x | 75% |
| 5 | 1.25x | 80% |
That is the even case, and practice is worse:
- GeoDNS is not even. It sends all of a dead region’s users to the nearest survivor. In the demo, that doubles one region’s load. A planned failover map that splits them restores the 1.5x.
- Survivors start cold. So the databases behind their caches see more than 1.5x for minutes.
- Clients reconnect at once. That is why the chat and collaborative editor add jitter to reconnects.
- Autoscaling is minutes behind. So load shedding protects the region in the meantime.
Size for N / (N - 1) of the busiest hour, not the average.
Failover that was never tested
Section titled “Failover that was never tested”The standby path runs only during a disaster, so it rots: a missing secret, a firewall rule nobody copied, a lapsed capacity reservation. Game days fail a region on purpose, in production, at a scheduled time: first by draining its traffic, later by cutting it off. Netflix runs this as Chaos Kong, exercises that evacuate all traffic from one AWS region to the others. A drill measures the real RTO and RPO, and the first one nearly always finds something.
Failback is a second failover: the old region catches up, its stranded writes are reconciled, and traffic moves back on purpose, off-peak.
Go deeperEvery failure mode at a glance1 min
| Failure | Blast radius | Detect | Fix |
|---|---|---|---|
| Split brain after a partition | Every record written on both sides | Two regions claim the same epoch; write divergence | Majority across 3 places, leases, epochs checked by every copy |
| Servers fail over without data | Every write after failover | Write success rate per region, synthetic writes | Promote before routing; health checks that write |
| Lag lost on failover | Writes in the lag window | Cross-region lag in seconds and bytes | Lag alerts, idempotent resubmits, keep the old disk, sync where it matters |
| Survivors overloaded | Every user, not just the failed region’s | Utilization vs (N - 1) / N; error rate after failover | Headroom, spread failover map, shedding, jittered reconnects |
| DNS stickiness | Clients that cached or kept connections | Traffic still arriving at the dead region | Short TTLs, re-resolve on reconnect, anycast or global LB |
| Untested standby path | The whole failover | Days since the last successful drill | Game days, regular region evacuations |
| A hidden single-region dependency | Every region | Dependency map; a drill that cuts the region | Run auth, config and secrets per region; fail over on the data plane |
At scale
Section titled “At scale”| What | Typical figure |
|---|---|
| Round trip, zones in one region | 0.5 to 2 ms |
| Round trip, US coast to coast | 60 to 75 ms |
| Round trip, across the Atlantic | 70 to 90 ms |
| Round trip, US to Asia or Europe to Asia | 100 to 230 ms |
| Async replication lag | 100 ms to a few seconds normally; tens of seconds under load |
| 99.99% yearly budget | 52 minutes: one human-decided failover can use a third of it |
At 10 times, a region stops being one unit. Each region runs several cells, and homes are assigned to cells. Failover moves cells, which can be rehearsed one at a time. Cross-region transfer becomes a line item. Compress replication. Replicate only what another region needs: timelines in the news feed stay home, and are rebuilt on failover. And keep chatty internal calls inside a region.
At 100 times, there are 6 to 12 regions and N / (N - 1) becomes cheap: at 8 regions survivors need only 14% more. Failover maps are computed from live capacity, evacuation is routine, and the home directory and failover controller are the most protected systems in the company.
What to monitor:
- Cross-region replication lag per stream, in seconds and bytes: the data a failover would lose now.
- Write and read success per region from external probes, including a real write.
- Each region’s peak utilization against (N - 1) / N and against the failover map.
- Traffic share per region, to spot clients stuck on the old region after a move.
- Every epoch and ownership change in the directory, with who made it.
- Days since the last successful region drill, and the RTO and RPO it measured.
In the interview
Section titled “In the interview”Interviewer asksThe service runs in us-east. Leadership wants it multi-region. What do you do?
First I ask which reason applies: latency, surviving a region, or residency. I also ask what RPO and RTO the business accepts, because those pick the topology. For availability alone, with users in one area, a hot standby region with async replication works. It gives minutes of RTO and seconds of RPO, for about twice the cost. For global users, every region serves, and each user has a home region for writes. Writes are local, there are no conflicts, and a region failure touches only its own users. Usernames or balances go to one owner, or a consensus database. With 3 regions, each runs at most 67% at peak.
Interviewer asksUsers in the US and Europe both edit their own profiles and documents. How do you make writes active-active?
Not by letting every region write every document. Each user and document gets a home region, near most of its editors. Gateways everywhere route writes there, using a cached directory or region bits in the ID. Your own writes are local. A collaborator abroad pays one hop. Reads use the local copy when a second of staleness is fine. View counts and presence are written in every region, as CRDTs. Only a document with live editors on two continents makes me consider a CRDT for the document itself.
Interviewer asksThe home region dies with async replication. What do you lose, and how do you avoid two primaries?
The lag’s worth of writes, for users homed there. At one second and 1,000 writes a second, that’s about 1,000. Clients resubmit with idempotency keys, and we keep the dead primary’s disk to reconcile the rest. Against two primaries: promotion needs a majority across three regions. So a region that is only cut off cannot promote itself. And the old primary stops writing when its lease expires. Each promotion bumps an epoch that every copy checks, so the old primary’s late writes are refused.
Seen in
Section titled “Seen in”- URL shortener, pastebin and proximity service: the read path in every region, creates or owner edits in one home region.
- News feed: a home region per user for timelines, feeds rebuilt by pull after a failover.
- Chat and collaborative editor: a home region per conversation or document, because order needs one decider.
- Key-value store: multi-leader across two datacenters with LOCAL_QUORUM, each sized to carry both.
- Job scheduler: synchronous replication to a region 10 ms away for RPO zero, with epochs on takeover.
- File sync: an asynchronous second region, gated so metadata never points at missing blocks.
- Rate limiter: a global limit split into regional shares that follow traffic.
- Unique ID generator: region bits in every ID, so regions mint with no coordination.
- Top-k and ad click aggregator: ingest in every region, then a global merge.
- Message queue and notification system: cells, with an asynchronous mirror or standby in a second region.
- Ticketmaster and hotel reservation: one home region per event or hotel, so two regions never sell the same seat or room.
- Payment system: the window lost in a failover rebuilt from the provider’s records.
Why. Latency for far users (70 to 230 ms a round trip across oceans). Surviving a region (one long outage spends years of a 99.99% budget). And residency. Cost. 1.3 to 2 times the hardware, cross-region transfer, write latency or conflicts, and operational work.
Two numbers. RPO is set by replication (async loses its lag), RTO by failover (detect, decide, promote, warm, move traffic).
Topologies.
- Backup and restore: hours.
- Pilot light: tens of minutes.
- Warm standby: minutes.
- Hot standby: minutes, with seconds of loss.
- Home regions: local writes, and a failure affects one region’s users.
- Multi-leader: only for mergeable data.
- Global consensus: RPO zero, with a cross-region round trip per write.
Routing. GeoDNS, anycast or a global load balancer reach a gateway. The gateway routes by home region. Residency narrows homes and failover lists.
Failure. Split brain without a majority and an epoch. Servers failing over without their data. Lag lost on failover. Survivors without N/(N-1) headroom. Standby paths never drilled. Automate traffic failover, and let a human approve promotions that lose data.
Further reading
Section titled “Further reading”- On this site: replication for async, sync and multi-leader mechanics, consistency models, CAP and PACELC, consensus, networking, load balancing and CDN. Scaling patterns that failover leans on: idempotency for resubmitted writes, backpressure for overloaded survivors, and resilience for timeouts and circuit breakers around a failing region.
- Disaster Recovery of Workloads on AWS: the four DR tiers, RPO and RTO, and failing over through the data plane.
- Spanner: Google’s Globally-Distributed Database, OSDI 2012: Paxos across regions, TrueTime and commit wait.
- Managing Critical State, Google SRE book: consensus across regions, replica placement and what it costs in latency.
The designbeyondcode digest, every Saturday
New deep dives, interview tips and site news in one short email. Free. Leave with one click.
We never sell or share your email. We only use it to send this newsletter.