S076 · Multi-Region — Active-Passive, Active-Active, Failover
Surviving whole-region outages without lying to your users. The three architectures, the two RTO/RPO knobs, and why 'active-active' is often a marketing term.
🎯 Choose an active-passive / pilot-light / active-active topology given RTO/RPO targets, and design a failover plan you'd actually trust at 3 AM.
Why this session exists
Once a year AWS us-east-1 has a bad day, Azure has a Front Door incident, or GCP loses networking in a region. Companies with real multi-region designs shrug and route around it in minutes. Companies without pay for it in headlines, refunds, and CEO apologies. Multi-region is expensive, complicated, and — done wrong — actually increases your outage surface. This session gives you the mental model to decide whether you need it, and the vocabulary to design it if you do.
- Define RTO and RPO in plain English and set numeric targets for a hypothetical product.
- Compare active-passive, pilot-light, warm-standby, and active-active on cost / RTO / complexity.
- Explain why data replication is the hard part and why 'sync replication across continents' is usually a bad idea.
- Design a DNS-based failover plan including health checks, TTLs, and the split-brain risk.
- List the three biggest failure modes of multi-region (data divergence, DNS lag, dependency asymmetry).
Prerequisites
- S068 · Replication basics — the primitives you'll wire together across regions.
- S072 · Consistency models — cross-region consistency is where the physics of light bites.
- S073 · Consensus — Raft/Paxos across regions is 100+ ms per write; know why.
(a) Intuition · 5 min
A hospital has three options against a citywide power outage: batteries in a closet (turn on manually, 15 min gap in lights), an idle generator (auto-starts, 30 s gap), or two independent grids running in parallel (zero gap, twice the electricity bill).
Every option is legitimate. Which you pick depends on what happens in the gap. Surgeries in progress? Zero-gap only. Office lighting? Batteries are fine.
Multi-region is exactly this menu. Backup-and-restore: your data is in another region as backups, you rebuild the app if the primary dies (hours). Pilot-light: a minimal always-on copy in another region; you scale it up on failover (10-30 min). Warm standby: a smaller-but-live copy ready to take traffic (1-5 min). Active-active: both regions serve production traffic all the time (seconds, if done well).
The right choice depends on two numbers: RTO (how long can you be down?) and RPO (how much data can you lose?). Every architecture is a specific point on the cost/RTO/RPO surface.
RTO and RPO — the two numbers everything else derives from
- RTO (Recovery Time Objective) — how long the outage can last before it materially hurts the business. 'The site can be down for 4 hours' means RTO = 4h.
- RPO (Recovery Point Objective) — how much data you can lose. 'We can afford to lose the last 15 minutes of writes' means RPO = 15 min.
- RTO=0 requires active-active (or damn close). RPO=0 requires synchronous cross-region replication (expensive, high latency).
- Most SaaS lands at RTO=30 min, RPO=5 min — achievable with warm standby + async replication.
- Financial ledgers push RPO to zero — synchronous multi-region commits — and eat 100+ ms per write.
A quick history — how we got here
- 2005AWS us-east-1 launchesFor a decade, 'multi-region' meant 'use another cloud'. Nobody actually did it.
- 2012Netflix Chaos Monkey → Simian ArmyNetflix decides to intentionally test region failures. Multi-region becomes the differentiator between 'available' and 'sometimes available'.
- 2015AWS Route 53 health checks + failoverDNS-based failover becomes cheap and pushbutton. Small companies start doing it too.
- 2017AWS S3 us-east-1 outageS3 down for 4 hours; half the internet breaks. Wake-up call: even 'the AWS control plane' has regional dependencies.
- 2019Google Cloud Spanner GAFirst mainstream globally-consistent SQL database. Trades latency for global RPO=0.
- 2021AWS us-east-1 major outageMulti-hour incident. Companies with multi-region were fine; companies without lost the entire day.
- 2024Cross-region as default in serverlessDynamoDB Global Tables, Aurora Global Database, S3 Multi-Region Access Points — 'multi-region' is now a checkbox.
(b) Visual walkthrough · 15 min
The four topologies
The failover sequence — what actually happens at 3 AM
The five moving parts of a multi-region design
DNS-based (Route 53, Cloudflare) or Anycast (CloudFront, Cloud Load Balancer). Determines how fast failover propagates.
Stateless services in each region. Easy — just deploy the same containers.
The hard part. Async replication (RPO > 0) or synchronous consensus (RPO=0, high latency).
Where does a user's session live? Sticky-to-region or replicated? Often forgotten until failover.
The plan to declare failover, the tested procedure to flip, and the practice runs. Untested plans fail.
Data layer choices — the entire complexity
How data crosses regions
Side by side — three real production topologies
Each region can serve any user; users pinned to nearest home
- 3 AWS regions active
- Cassandra replicated with tunable consistency
- Chaos Monkey / Simian Army test regional failure weekly
- Cost: 3x infrastructure for 1x traffic (over-provisioned)
- Reward: entire region can die with ~2 min customer impact
Primary + secondary, async replication
- 1 primary region running production
- 1 secondary region with app deployed but scaled down
- Async DB replication (RPO ~30s to 5min)
- Failover triggered manually via runbook (RTO 10-30 min)
- Cost: ~1.2x infra + engineering time
Ledger MUST be RPO=0
- Ledger DB uses Spanner / CockroachDB / synchronous mirroring
- Every write acks after cross-region quorum (~50-100 ms)
- App is warm-standby (RTO minutes)
- Read-only replicas per region for query performance
- Cost: 3-5x, unavoidable — money can't be RPO > 0
The mental model to hold
"Deploying to a second region gives me high availability. If one region fails, traffic moves to the other and users barely notice."
A second region gives you the capability to fail over. Whether you actually can depends on things most teams have not verified: does the second region have enough capacity to take 100% of traffic, is its data current, does failover require a control-plane operation that may itself be affected, and has anyone executed it recently? An untested failover path is a hypothesis, and a multi-region deployment where the second region has never served production traffic is a very expensive hypothesis.
The myth is sticky because the deployment topology is visible and checkable — resources exist in two regions, the diagram has two boxes — while the failover procedure is invisible until executed. It is also true that the hard infrastructure work is genuinely done. What remains undone is everything that only reveals itself under a real failover: a hardcoded regional endpoint, a certificate provisioned in one region, a quota never raised in the standby, a database whose promotion takes far longer than anyone assumed.
The only test that means anything is executing the failover:
Questions a game day answers and a diagram cannot:
1. Does region B have quota for 100% of traffic, right now?
2. How long does the database promotion actually take?
3. Does failover need the control plane, which may be degraded?
4. What is the replication lag at this moment (= data loss)?
5. Can you fail BACK, or is this one-way?
If you cannot answer all five from a recent test,
you have a standby region, not a failover plan.Why is active-active multi-region so much harder than active-passive, when both have the same infrastructure in both places?
- 1Active-passive has exactly one region accepting writes at any time, so there is a single authority on write order.forced by · only one node accepts writes, which makes ordering a local property requiring no coordination
- 2Active-active accepts writes in both regions concurrently, so two writes to the same entity may be accepted independently with no knowledge of each other.forced by · coordinating on every write would cost a cross-region round trip, which is the exact latency active-active exists to avoid
- 3Therefore conflicts are possible, and there is no globally correct resolution available after the fact — the system must be told what to do.forced by · both writes were accepted and acknowledged, so neither can be declared invalid without violating something already promised to a client
- 4Conflict resolution is application semantics, not infrastructure: last-writer-wins silently discards a real change, merging requires knowing what the fields mean, and some operations cannot be merged at all.forced by · only the application knows whether two concurrent updates to a record are compatible or mutually exclusive
- 5So active-active pushes a genuinely hard, domain-specific problem into every write path, whereas active-passive eliminates it entirely by construction.forced by · a problem that cannot occur needs no solution, and single-writer makes concurrent conflicting writes impossible
Therefore the difficulty of active-active is not operational complexity — it is that it converts a solved problem (write ordering) into an unsolved one (conflict resolution) that must be answered per data type.
And note what this predicts: active-active is tractable exactly where writes can be partitioned by region so conflicts cannot arise — each user's data has a home region, and writes for that user always go there. That is why real active-active deployments are usually partitioned active-active rather than truly symmetric. It also predicts that append-only and commutative data types are far easier to run active-active than mutable records, which is why event-shaped designs appear so often in multi-region systems.
Multi-region availability is not something your architecture has; it is something a sequence of steps achieves, executed under stress, possibly by someone who has not done it before. Every step that requires a human decision, a control-plane call, or a DNS change is a step that can fail or be slow at exactly the wrong time.
Measure it the way the business does: RTO is how long recovery takes, RPO is how much data is lost. Both are numbers you must be able to state and have recently demonstrated.
- Failover must not depend on the failing region, and must not depend on a control plane that may be affected. Anything requiring resource creation during an incident is unavailable exactly when needed — pre-provision standby capacity rather than planning to scale it up.
- Traffic steering mechanisms have very different characteristics: DNS is universally supported and slow to take effect because of caching you do not control; anycast and global load balancers shift in seconds but cost more and are themselves a dependency. Know which you have and what its actual switch time is.
- Replication lag is your RPO expressed as a live metric. It is not a target in a document — it is the current amount of data you would lose if the region failed this second, and it should be graphed and alerted on for that reason.
- Test failover on a schedule, in production, with the real procedure. A failover path exercised only during incidents is a path with unknown correctness, and every untested assumption in it will be discovered at the worst possible moment.
Fire this model when you see: a standby region that has never taken traffic · a runbook last updated a year ago · a DR plan requiring resource creation · a regional endpoint hardcoded in configuration · nobody able to state the current RPO.
Active-passive with failover, or active-active with traffic in both regions?
Active-passive with a genuinely tested failover beats active-active that nobody fully understands. The second region's value comes almost entirely from whether the failover works, and a simple design that has been exercised is worth far more than a sophisticated one that has not.
Move to partitioned active-active when latency for distant users becomes a real product problem, and treat fully symmetric active-active as a specialist choice requiring a data model built for it from the start. Whichever you choose, the discipline that matters most is the same: schedule a failover exercise, run it in production, and treat any step that surprises you as a defect to fix rather than a quirk to remember. The regions are the easy part; the procedure is the product.
(c) Hands-on · 25 min
Let's simulate a two-region deployment with async replication, then trigger a failover and measure the RPO (data lost) and RTO (time to recovery).
#!/usr/bin/env python3
"""multi_region_sim.py — a toy simulation of an active-passive setup
with async replication, health checks, and failover.
Run: python multi_region_sim.py
No dependencies.
Simulates:
- Region A (primary): accepts writes, replicates to B
- Region B (secondary): reads only, tracks replication lag
- A user client that fails over to B based on health checks
- We kill A partway through and measure lost writes (RPO) and
time-to-first-successful-write on B (RTO).
"""
from __future__ import annotations
import random
import threading
import time
from dataclasses import dataclass, field
@dataclass
class Region:
name: str
is_primary: bool = False
healthy: bool = True
accepts_writes: bool = False
store: dict[int, str] = field(default_factory=dict)
# Async replication queue: (msg_id, value, ts_written)
replication_queue: list[tuple[int, str, float]] = field(default_factory=list)
lock: threading.Lock = field(default_factory=threading.Lock)
def write(self, key: int, value: str) -> bool:
if not (self.accepts_writes and self.healthy):
return False
with self.lock:
self.store[key] = value
self.replication_queue.append((key, value, time.time()))
return True
def read(self, key: int) -> str | None:
if not self.healthy:
return None
with self.lock:
return self.store.get(key)
def apply_replicated(self, key: int, value: str) -> None:
with self.lock:
self.store[key] = value
class Replicator:
"""Streams from a source region's queue to a target region's apply()."""
def __init__(self, source: Region, target: Region, lag_ms: int = 100):
self.source, self.target = source, target
self.lag_ms = lag_ms
self.stopped = False
self.applied_count = 0
threading.Thread(target=self._loop, daemon=True).start()
def _loop(self) -> None:
while not self.stopped:
time.sleep(0.02)
with self.source.lock:
if not self.source.healthy:
# Source is down — replication halts. This is the whole point.
continue
batch = list(self.source.replication_queue)
self.source.replication_queue.clear()
for key, value, ts in batch:
# Simulate network latency between regions.
time.sleep(self.lag_ms / 1000.0 / max(len(batch), 1))
self.target.apply_replicated(key, value)
self.applied_count += 1
def stop(self) -> None:
self.stopped = True
class HealthChecker:
"""Checks a region every check_interval; declares unhealthy after
fail_threshold consecutive failures."""
def __init__(self, region: Region, check_interval: float = 0.5,
fail_threshold: int = 3):
self.region = region
self.interval = check_interval
self.threshold = fail_threshold
self.consecutive_failures = 0
self.declared_unhealthy = False
self.stopped = False
threading.Thread(target=self._loop, daemon=True).start()
def _loop(self) -> None:
while not self.stopped:
time.sleep(self.interval)
if self.region.healthy:
self.consecutive_failures = 0
self.declared_unhealthy = False
else:
self.consecutive_failures += 1
if self.consecutive_failures >= self.threshold:
self.declared_unhealthy = True
def stop(self) -> None:
self.stopped = True
class Client:
"""Writes to whichever region the health checker declares healthy."""
def __init__(self, primary: Region, secondary: Region,
hc_primary: HealthChecker, hc_secondary: HealthChecker):
self.primary, self.secondary = primary, secondary
self.hc_p, self.hc_s = hc_primary, hc_secondary
self.writes_ok = 0
self.writes_failed = 0
def write(self, key: int, value: str) -> None:
if not self.hc_p.declared_unhealthy and self.primary.write(key, value):
self.writes_ok += 1
return
# Failover — try secondary
if not self.hc_s.declared_unhealthy and self.secondary.write(key, value):
self.writes_ok += 1
return
self.writes_failed += 1
def main() -> None:
region_a = Region("region-a", is_primary=True, accepts_writes=True)
region_b = Region("region-b", is_primary=False, accepts_writes=False)
repl = Replicator(region_a, region_b, lag_ms=200)
hc_a = HealthChecker(region_a, check_interval=0.3, fail_threshold=3)
hc_b = HealthChecker(region_b, check_interval=0.3, fail_threshold=3)
client = Client(region_a, region_b, hc_a, hc_b)
print("Phase 1: normal operations for 2s")
start = time.time()
key = 0
while time.time() - start < 2.0:
client.write(key, f"v{key}")
key += 1
time.sleep(0.005)
print(f" writes_ok={client.writes_ok}, "
f"region_a store size={len(region_a.store)}, "
f"replicated to B={repl.applied_count}")
print("\nPhase 2: KILL region A")
region_a.healthy = False
kill_time = time.time()
last_replicated_at_kill = repl.applied_count
a_size_at_kill = len(region_a.store)
# Wait for health check to declare A unhealthy, then failover B
while not hc_a.declared_unhealthy:
time.sleep(0.05)
detect_time = time.time()
print(f" A declared unhealthy after {(detect_time - kill_time)*1000:.0f}ms")
# Enable writes on B (manual failover step)
region_b.accepts_writes = True
failover_time = time.time()
print(f" B accepting writes at t={(failover_time - kill_time)*1000:.0f}ms after A died")
# RPO measurement: how many writes were on A but not yet replicated to B?
rpo_lost_writes = a_size_at_kill - repl.applied_count
print(f" RPO: {rpo_lost_writes} writes were on A but never made it to B")
print("\nPhase 3: continue writing (now to B)")
first_success_after_failover = None
for _ in range(200):
before = client.writes_ok
client.write(key, f"v{key}")
key += 1
if first_success_after_failover is None and client.writes_ok > before:
first_success_after_failover = time.time()
time.sleep(0.005)
if first_success_after_failover:
rto = first_success_after_failover - kill_time
print(f" RTO: {rto*1000:.0f}ms from A-dies to first successful write on B")
print(f"\nFinal: writes_ok={client.writes_ok}, writes_failed={client.writes_failed}")
repl.stop(); hc_a.stop(); hc_b.stop()
if __name__ == "__main__":
main()What each block does
Anatomy of the script
Rewrite Client.write to pick randomly:
region = random.choice([self.primary, self.secondary])
if region.write(key, value):
self.writes_ok += 1Run and diff the two stores at the end:
diff = {k for k in region_a.store if region_a.store.get(k) != region_b.store.get(k)}
print(f"divergent keys: {len(diff)}")You'll see hundreds of divergent keys — the classic multi-master conflict. Real systems solve this via (a) sticky routing per key, (b) vector clocks + merge, or (c) CRDTs (grow-only sets, counters).
(d) Production reality · 15 min
An automated scaling event in us-east-1's internal network caused a cascade: EC2 API calls started failing, then Cognito, then Kinesis, then anything that transitively depended on the metadata service. Effectively the entire us-east-1 control plane was down for hours.
Companies without multi-region were fully down. Companies with active-active saw a bump but stayed up. Companies with 'multi-region' that actually meant 'DR in us-east-2 with a manual runbook' spent 3-5 hours failing over — because runbooks that hadn't been tested in 6 months contained stale steps.
Where this shows up in the rest of the plan
(e) Recall + stretch · 10 min
Explain-out-loud test
Teach these three, no notes:
- RTO vs RPO — one sentence each, plus a real product for each.
- The four topologies — cost/time tradeoff.
- Why 'active-active' is often marketing — and the one question to ask to catch it.
Hub: The 6-Month Learning Plan
Part of a 130-session evergreen learning series. Session structure: intuition → visual → hands-on → production war stories → recall. Duration: 90 minutes.