Sheet ⁨02⁩ · ⁨Blog⁩Surveyed ⁨2026⁩

Blog post image for Multi-Region Active-Active on AWS: What It Actually Costs You - A practical look at running two AWS regions live at once: Route 53 routing and ARC, DynamoDB Global Tables, Aurora Global Database versus Aurora DSQL, conflict resolution patterns, and the cost and consistency trade-offs nobody mentions up front.

Multi-Region Active-Active on AWS: What It Actually Costs You

Published: Updated: 12 Mins read18 Mins listen
Markdown for AI(opens in a new tab)

Surviving a full regional outage with no downtime is the thing every architecture review eventually asks about, and the answer is usually more expensive than the person asking expects. In a multi-region active-active setup, two or more AWS regions serve live production traffic at the same time. If one region fails badly, the others keep serving without a manual failover and without waiting for anything to warm up.

Managed services have made this much more approachable than it was five years ago. What has not changed is that you are now running a distributed system with a data layer spanning thousands of kilometres, and the failure modes are less “region goes dark” and more “two regions disagree about the truth.”

Active-active versus active-passive, and why the distinction costs money

Decide which problem you are solving before you build anything. Multi-region deployments fall into two families, and treating one as the other is how teams end up paying for resilience they never asked for.

Active-passive sends all production traffic to one primary region while a secondary sits ready. That readiness has degrees. A pilot light replicates data and keeps compute switched off. A warm standby runs a scaled-down copy of the application all the time. When something breaks you scale the standby up and move DNS. It works, and it means accepting a recovery time measured in minutes or hours.

Active-active is a different animal. Both regions serve live traffic continuously, both hold a complete application stack, and the data in each is live and writable.

Active-passive (warm standby)Active-active
Recovery timeminutes to hoursnear zero
Trafficone region takes it allspread across regions
Replicationone direction, primary to standbybidirectional or multi-primary
Failure modesmostly “did the failover work”disagreement, split brain, conflicts
Cost shapebase compute plus replication2x compute plus heavy data transfer

Active-active earns its complexity when you genuinely need near-zero recovery time, or when a globally spread user base needs low latency from a nearby region. If what you actually need is to survive a bad day with an hour of degradation, warm standby costs a fraction as much and has far fewer ways to surprise you.

The honest framing: active-passive is a recovery strategy, active-active is an architecture. Partial failures are the hard part, not total ones. A region that is up but returning errors for 3% of requests is much harder to handle than a region that has plainly vanished.

What the whole thing looks like

Both regions are complete, independent cells. Route 53 picks the nearest healthy one, ARC gives you a deliberate switch when health checks are too slow, and only the data layer crosses the region boundary.

Two properties in that diagram matter more than the service choices. First, each region is a complete cell that can serve any request on its own. Second, nothing in the request path crosses a region boundary. The moment Region A makes a synchronous call to something in Region B, you have two regions and one failure domain, which is worse than a single region because you are paying twice for it.

Routing traffic with Route 53

Your ingress layer decides which region a user reaches. Latency-based routing uses AWS telemetry to send each user to the region with the lowest measured network latency to them. Geolocation routing maps the resolver’s location to a region, which is what you want for data residency rather than speed.

Create one record per regional endpoint, distinguished by set_identifier:

resource "aws_route53_record" "api_eu" {
zone_id = aws_route53_zone.main.zone_id
name = "api.example.com"
type = "A"
alias {
name = aws_lb.eu_west_1.dns_name
zone_id = aws_lb.eu_west_1.zone_id
evaluate_target_health = true
}
latency_routing_policy {
region = "eu-west-1"
}
set_identifier = "eu-west-1-endpoint"
}
resource "aws_route53_record" "api_us" {
zone_id = aws_route53_zone.main.zone_id
name = "api.example.com"
type = "A"
alias {
name = aws_lb.us_east_1.dns_name
zone_id = aws_lb.us_east_1.zone_id
evaluate_target_health = true
}
latency_routing_policy {
region = "us-east-1"
}
set_identifier = "us-east-1-endpoint"
}

With evaluate_target_health = true, Route 53 stops returning a record whose load balancer is failing, and traffic lands on whatever is left.

Careful here

DNS failover is not instant, and the delay is mostly not AWS. Health checks need several consecutive failures to trip, then your TTL has to expire, and then you are at the mercy of resolvers and HTTP clients that cache DNS for longer than they should. Java applications with a JVM-level DNS cache are a classic offender. Budget minutes, not seconds, and never describe DNS failover as zero-downtime.

This is why Route 53 Application Recovery Controller exists. Routing controls are on/off switches you flip yourself, backed by a cluster of five regional endpoints so you can still operate the switch during an event that is affecting your own tooling. Readiness checks separately audit whether the standby region is actually capable of taking the traffic, which catches the quota and capacity problems that only show up at the worst moment.

The useful mental model: health checks are for failures you did not predict, routing controls are for decisions you make. Draining a region because latency is climbing and you want out is a decision, not a health check.

The data layer is the whole problem

Everything above is comparatively easy. Keeping data correct in two places that are 80 milliseconds apart is where the difficulty lives, and the right answer depends on what your writes actually do.

Start from whether concurrent writes to the same record are even possible, then from whether losing one would cause harm. Most systems land on different answers for different tables.

DynamoDB Global Tables

A global table is a set of replica tables in different regions sharing a primary key schema. Write in eu-west-1, and DynamoDB propagates that write to us-east-1 asynchronously using streams. Replication is typically well under a second within a continent, and it is never guaranteed.

resource "aws_dynamodb_table" "users" {
name = "users"
billing_mode = "PAY_PER_REQUEST"
hash_key = "user_id"
stream_enabled = true
stream_view_type = "NEW_AND_OLD_IMAGES"
attribute {
name = "user_id"
type = "S"
}
replica {
region_name = "eu-west-1"
}
replica {
region_name = "us-east-1"
}
lifecycle {
# Replicas drift outside Terraform when you add or remove regions
# through the console during an incident. Ignoring them here stops
# the next plan from proposing to tear one down.
ignore_changes = [replica]
}
}

Streams with NEW_AND_OLD_IMAGES are required, because that is the mechanism replication uses.

The behaviour you have to design around is conflict resolution. Global tables use last-writer-wins based on timestamp. Two regions updating the same item within the replication window means one update is silently discarded, with no error and no log entry on your side. For a user’s display name, that is acceptable. For a balance, it is data loss with extra steps.

Aurora Global Database, and its one writer

Aurora Global Database gives you a primary region and up to five secondaries with replication latency typically under a second, plus fast promotion. It has one hard constraint: a single writer. Every write commits in the primary region.

Local write forwarding softens this. Applications connect to a secondary’s reader endpoint, issue writes, and Aurora forwards them over the AWS backbone to the primary. Your code stops caring which region it is in. Your latency budget still cares, because every forwarded write pays the round trip. At 80 milliseconds between regions, a transaction doing four sequential writes has just spent a third of a second on network time.

Call this what it is: active-active for reads, active-passive for writes. That is a legitimate and common design, and it is worth being precise about, because “we run Aurora Global Database” and “we are active-active” are different claims.

Aurora DSQL

Aurora DSQL is the option that changes the answer for relational workloads. It is serverless, PostgreSQL-compatible, and built for active-active from the start, separating compute, storage, and transaction coordination so each scales independently.

The interesting part is the concurrency model. Rather than taking locks as a transaction proceeds, DSQL uses optimistic concurrency control and defers coordination to commit time. Because consensus happens synchronously at commit, a read in one region reflects a committed write from another, which is what people usually mean by “no replication lag.” AWS publishes a 99.999% availability SLA for multi-region configurations.

What you pay for that is a different failure mode in your application code. Transactions that conflict get rejected at commit, and the client is expected to retry:

import psycopg
from psycopg import errors
def transfer(conn_factory, from_id, to_id, amount, attempts=5):
"""DSQL rejects conflicting transactions at COMMIT rather than blocking.
Retrying is not optional error handling, it is the concurrency model."""
for attempt in range(attempts):
try:
with conn_factory() as conn, conn.transaction():
cur = conn.execute(
"SELECT balance FROM accounts WHERE id = %s", (from_id,)
)
(balance,) = cur.fetchone()
if balance < amount:
raise ValueError("insufficient funds")
conn.execute(
"UPDATE accounts SET balance = balance - %s WHERE id = %s",
(amount, from_id),
)
conn.execute(
"UPDATE accounts SET balance = balance + %s WHERE id = %s",
(amount, to_id),
)
return True
except errors.SerializationFailure:
if attempt == attempts - 1:
raise
# Full jitter. Retrying in lockstep just recreates the collision.
time.sleep(random.uniform(0, 0.1 * (2**attempt)))
return False

If your team has never written retry-on-serialization-failure code, that is the real migration cost, not the SQL compatibility.

Conflict resolution, cheapest option first

Once you accept writes in more than one region, the CAP theorem stops being an interview question. During a partition between regions you either keep accepting writes and let the data diverge, or you refuse writes and stay consistent. Most businesses pick availability and then need a plan for divergence.

The cheapest plan is to make conflicts impossible rather than resolving them. Region affinity assigns each user a home region and routes their writes there, while reads can happen anywhere. Since most records in most systems are only ever written by one user, this eliminates the large majority of conflicts for the cost of a routing rule. Reach for it first.

When two regions genuinely must write the same record, the next cheapest option is to make ordering irrelevant. A DynamoDB ADD on a counter is commutative, so increments from either region compose correctly no matter what order they arrive in. Set unions behave the same way. This works beautifully for likes, view counts, and tag sets, and not at all for anything where the new value depends on reading the old one.

Last-writer-wins is what you get by default from Global Tables, and it is a real choice rather than an absence of one. It is correct whenever the most recent write is genuinely the one you want and losing the other causes no harm.

Optimistic concurrency control is the option that refuses to lose a write. The system detects the collision at commit, rejects one transaction, and hands the problem to the client. This is DSQL’s model, and you can approximate it in DynamoDB with a version attribute and a condition expression:

// Conditional write: fail rather than clobber a concurrent update.
await ddb.send(
new UpdateItemCommand({
TableName: 'orders',
Key: {order_id: {S: orderId}},
UpdateExpression: 'SET #s = :next, version = :newV',
ConditionExpression: 'version = :curV',
ExpressionAttributeNames: {'#s': 'status'},
ExpressionAttributeValues: {
':next': {S: 'shipped'},
':curV': {N: String(currentVersion)},
':newV': {N: String(currentVersion + 1)},
},
}),
);
// A ConditionalCheckFailedException means someone else won. Re-read and retry.

Note

A condition expression is evaluated against the local replica, so it protects you against concurrent writes in the same region but not against a simultaneous write in another region that has not replicated yet. Optimistic locking on an eventually consistent multi-region table narrows the window, it does not close it. If you need it closed, you need a system that reaches consensus at commit.

Cross-region networking

Internal service calls and replication need a path between regions. For two or three regions, cross-region VPC peering is usually right: direct, simple, and cheaper because you avoid per-attachment and per-GB processing fees.

Transit Gateway earns its cost once you have many VPCs or on-premises connectivity to fold in, because maintaining a peering mesh and its route tables stops being tractable. Be clear-eyed about the pricing though. You pay hourly per attachment plus per-GB processing, and that processing charge sits on top of standard inter-region transfer. In a chatty active-active application, that stacking is the line item that shows up in a quarterly review with someone asking what happened.

The cheaper architectural answer is usually to move less data. Region-local caches, region-local queues, and asynchronous replication instead of synchronous calls all reduce the bill and the coupling at the same time.

Testing it, or admitting you have not

An active-active architecture you have never failed over is a hypothesis, not a capability.

Configuration drift between two supposedly identical regions is not a risk, it is a certainty. Emergency patches, manual parameter changes, a security group tweaked at 2am, a quota raised in one region and not the other. Each is individually reasonable and collectively guarantees that your secondary region is not quite what you think it is. The failure you will actually hit is not “the region was down”, it is “the other region could not scale because nobody raised the account limit there.”

  1. Make readiness a check, not a belief. Route 53 ARC readiness checks compare capacity, quotas and configuration between regions and tell you when they diverge. Run them continuously, not before a game day.

  2. Practise on one dependency first. Break a single service’s connection to its replica in a non-production account and confirm the alarms you expect actually fire. Most first attempts discover a monitoring gap rather than an architecture gap.

  3. Drain a region in production, deliberately, during business hours. Use an ARC routing control, watch the traffic move, and keep the switch in your hand. Doing this while everyone is awake and expecting it is how you find the problems cheaply.

  4. Automate the runbook, then delete the manual one. If failover needs a human to read a wiki page and run six commands in order, it will not happen correctly at 3am. If it needs one command, it will.

  5. Rehearse on a schedule and record the drift you find. The value is not proving it works, it is the list of things that had quietly broken since last time.

I have written up how this played out on a real system, including the numbers, in the multi-region active-active payments API case study.

Frequently Asked Questions

Compute roughly doubles, because you are running a full stack in both regions and cannot size either at half capacity if it must absorb all traffic during a failure. The part people underestimate is data transfer: every replicated write crosses a region boundary at per-GB rates, and chatty cross-region service calls multiply that. In the environments I have seen, inter-region transfer plus the second stack lands somewhere above 2x rather than at it. Model the transfer volume from your actual write rate before committing.

Not honestly, if you mean writes in both regions. Aurora Global Database and standard cross-region read replicas both have one writer, so what you get is active-active reads and active-passive writes. Multi-primary MySQL and bidirectional logical replication exist, and they push conflict resolution into your application while making schema changes genuinely dangerous. If you need real multi-region relational writes, Aurora DSQL is the option built for it. Otherwise design around a single writer and be precise about what you are claiming.

Keep them region-local and make them reconstructible. Replicating cache state across regions costs money to achieve something you did not need, since a cache miss is not a failure. For sessions, either use a stateless token the other region can validate on its own, such as a signed JWT with a shared verification key, or store session state in the global table you already have. The pattern to avoid is a region-local session store combined with DNS that can move a user mid-session, which logs people out during exactly the event you built this for.

Anything with a health check that only asks “is the process alive.” A region returning 500s for a subset of requests keeps passing a shallow health check, so Route 53 keeps sending it traffic. Deep health checks that exercise a real dependency path fix this, and composite CloudWatch alarms wired to an ARC routing control let you drain deliberately when the signal is ambiguous. Gray failures are the reason routing controls exist, and the reason a purely automatic design is not enough.

Two is enough for surviving one region, which is the requirement almost everyone actually has. Three matters when your data layer needs a quorum, since three regions can tolerate losing one and still form a majority while two cannot. It also helps when capacity headroom is expensive, because with three regions each needs 50% spare rather than 100%. Start with two unless a quorum-based store or the capacity maths pushes you further.

Plan for minutes. Route 53 health checks need multiple consecutive failures before a record is withdrawn, typically around 30 to 90 seconds depending on interval and threshold. Then your record TTL has to expire, and then whatever caches DNS between you and the user has to agree, which is where the real variance lives. Some clients cache far longer than the TTL, and JVM defaults are notorious. If your requirement is single-digit seconds, DNS is the wrong mechanism and you want Global Accelerator’s anycast addresses or a client that retries against a second endpoint.

They solve adjacent problems and often appear together. Route 53 decides which regional endpoint a name resolves to, and its weakness is caching. Global Accelerator gives you two static anycast IPs that never change, routes over the AWS backbone from the nearest edge, and shifts traffic away from an unhealthy endpoint in seconds because there is no DNS in the failover path. Use it when you need fast failover, non-HTTP protocols, or fixed IPs for a client allowlist. It does not cache, so it is not a CloudFront substitute.

Region affinity plus DynamoDB Global Tables plus latency-based routing with deep health checks. That gives you two live regions, no write conflicts for the common case, and automatic removal of a failed region, without any distributed transaction machinery. Add ARC routing controls when you first meet a gray failure, and reach for DSQL only when you have a workload that genuinely needs cross-region relational consistency. Building the sophisticated version first is how these projects run for a year and ship nothing.

References

Was this useful?

You might also enjoy

More posts on similar topics

Navigating Growth: Building a Secure and Scalable AWS Environment with a Multi-Account Architecture and Control Tower

Navigating Growth: Building a Secure and Scalable AWS Environment with a Multi-Account Architecture and Control Tower

The cloud journey often kicks off with a single AWS account. It feels simple and straightforward, especially when you're just starting out or have smaller teams. But as your cloud usage grows, that in

FinOps in Practice: How to Build a Cloud Cost Accountability Culture on AWS

FinOps in Practice: How to Build a Cloud Cost Accountability Culture on AWS

Cloud computing changed how businesses pay for technology. Instead of a slow procurement cycle to buy physical servers, a single engineer can spin up thousands of dollars of infrastructure in minutes.

Testing Terraform: Static Analysis, Native Tests, and Terratest

Testing Terraform: Static Analysis, Native Tests, and Terratest

If you treat infrastructure as code, you have to test it like code. Most of us have lived the alternative. You change one input on a shared module, run a quick plan against staging, and merge. A few h

The AWS Well-Architected Framework Explained

The AWS Well-Architected Framework Explained

Introduction Imagine you built a new home that was carefully designed to match your specific needs. Yet as time passes, you begin to see some foundation cracks, roof leaks, and other problems that

Chaos Engineering: Testing Resiliency with Chaos Monkey and Gremlin

Chaos Engineering: Testing Resiliency with Chaos Monkey and Gremlin

Modern software systems are incredibly complex. They're spread across massive networks with countless moving parts. Because of this complexity, unexpected failures are inevitable. Servers crash. Netwo

Building Resilient Systems: Immutable Infrastructure with Packer and Terraform

Building Resilient Systems: Immutable Infrastructure with Packer and Terraform

What is immutable infrastructure? The way we manage IT infrastructure has really changed. We're moving from old-school, changeable setups to more modern, "immutable" ones. Understanding this big s

6 related posts