Zone-Aware Read Routing to Cut Cross-AZ Data Transfer
Problem statement: your application instances and read replicas are spread across three availability zones, every read has a two-in-three chance of crossing a zone boundary, and you are paying for that in both latency and per-gigabyte transfer charges.
Keeping reads in-zone is a straightforward optimisation with one serious trap: done as a hard partition it converts each zone into a single point of failure for the callers in it. This page implements it as a preference with automatic spill-over, which captures the benefit without the trap. It sits within load balancing reads across a replica pool.
Symptom Identification
Zone-aware routing is worth considering when:
- Cross-AZ data-transfer charges are a visible line item, and the traffic breakdown attributes most of it to database ports rather than to inter-service calls.
- p50 query latency measured from the application is meaningfully higher than the same query’s execution time measured on the replica — the gap is network round trip.
- A request that issues many sequential small reads has a latency profile dominated by round trips rather than by execution.
- Your application tier and replica tier are both spread across the same zones, so an in-zone target usually exists.
It is not worth doing when the read path issues few, large queries — there the transfer cost is the same wherever the query runs and the round-trip saving is amortised over a big result — or when your replicas are concentrated in fewer zones than your application instances, in which case most reads must cross anyway.
Root Cause Analysis
Cross-zone traffic is billed and it is slow, in that order of importance. The latency difference between an in-zone and cross-zone hop within one region is small in absolute terms — typically well under a millisecond — but it is paid per round trip. A read path that issues thirty sequential queries pays it thirty times, which is why the benefit is so much larger for chatty ORM code than for a service issuing one aggregate.
The transfer charge is the more predictable win. Read result sets are usually the highest-volume database traffic a service produces, and cross-zone transfer is charged per gigabyte in each direction on the major providers. The calculation is simple and worth doing before any configuration changes.
The obvious implementation is the dangerous one. Configuring each application zone to use only its local replicas produces perfect locality and a per-zone failure domain: when zone B’s replicas are down, or lagging past the freshness gate, every application instance in zone B loses its read path entirely — even though healthy replicas exist a millisecond away in zones A and C.
The fix is a preference with a fallback tier. HAProxy’s backup keyword expresses exactly this: backup servers receive no traffic while any non-backup server is available, and take over automatically when none is. The result is in-zone traffic under normal conditions and cross-zone traffic only when the local option has genuinely gone.
The capacity precondition is easy to skip. Zone preference concentrates each zone’s read load onto that zone’s replicas. If zone A hosts half your application instances but only a third of your replicas, enabling zone preference moves zone A’s replicas from comfortable to saturated on the day you turn it on. Check the ratio first.
Step-by-Step Resolution
Step 1 — Quantify what crossing actually costs you
-- Bytes returned by read queries, as a proxy for egress volume.
-- Run on each replica; the sum across replicas is your monthly read transfer.
SELECT sum(pg_column_size(t)) FROM (SELECT 1) t; -- placeholder for shape
-- Practical version: pg_stat_statements totals, scaled.
SELECT round(sum(rows * 200) / 1024.0^3, 1) AS approx_gb_returned_est
FROM pg_stat_statements WHERE query ~* '^\s*select';
-- 200 bytes/row is a rough average; replace with your measured row width.# The authoritative number comes from the cloud provider, not the database.
aws ce get-cost-and-usage \
--time-period Start=2026-07-01,End=2026-07-31 --granularity MONTHLY \
--metrics UnblendedCost \
--filter '{"Dimensions":{"Key":"USAGE_TYPE_GROUP","Values":["EC2: Data Transfer - Inter AZ"]}}'Inline verification: you have a monthly figure in currency. If it is small relative to the engineering effort, stop here — this optimisation is not free to operate.
Step 2 — Check per-zone capacity before concentrating load
# Application instances per zone — how the read load will distribute.
count by (zone) (up{job="app"})
# Replica capacity per zone — what will have to absorb it.
sum by (zone) (machine_cpu_cores{role="replica"})Compare the two ratios. If zone A holds 45% of application instances and 33% of replica capacity, enabling zone preference raises zone A’s replica load by roughly 35% relative to today. Confirm that headroom exists, or rebalance the replicas first.
Inline verification: for every zone, projected load (its share of app instances × total read rate) is below 60% of that zone’s replica capacity — leaving room for one replica to fail.
Step 3 — Configure preference, not partition
One backend per zone, with the other zones’ replicas present as backup:
backend pg_read_zone_a
balance leastconn
option pgsql-check user haproxy_check
default-server inter 2s fall 3 rise 5 slowstart 60s
# Local — normal traffic goes here.
server a1 10.0.1.11:5432 check weight 100
server a2 10.0.1.12:5432 check weight 100
# Remote — idle while any local server is up; automatic spill-over if not.
server b1 10.0.2.11:5432 check weight 100 backup
server c1 10.0.3.11:5432 check weight 100 backup
# Use ALL backups together rather than only the first one listed,
# so spill-over spreads across zones instead of hammering one replica.
option allbackupsoption allbackups is easy to miss and matters a great deal. Without it HAProxy sends all spill-over traffic to the first backup server only, so a zone-A outage concentrates zone A’s entire read load onto b1 alone.
Inline verification: echo "show stat" | socat stdio /var/run/haproxy.sock | grep pg_read_zone_a shows zero sessions on b1 and c1 while a1 and a2 are up.
Step 4 — Make the application select the right backend
# Kubernetes: expose the node's zone to the pod, then pick the backend by it.
env:
- name: AZ
valueFrom:
fieldRef:
fieldPath: metadata.labels['topology.kubernetes.io/zone']
- name: DB_READ_ENDPOINT
value: "pg-read-$(AZ).internal:5432" # resolves per-zone# Non-Kubernetes: read the zone from instance metadata once at start-up.
# Do NOT call this per request — it is a network round trip.
import os, urllib.request, functools
@functools.lru_cache(maxsize=1)
def availability_zone() -> str:
if (z := os.environ.get("AZ")):
return z
req = urllib.request.Request(
"http://169.254.169.254/latest/meta-data/placement/availability-zone")
with urllib.request.urlopen(req, timeout=1) as r:
return r.read().decode()
READ_ENDPOINT = f"pg-read-{availability_zone()}.internal"Inline verification: each application instance logs the endpoint it resolved at start-up, and the distribution of endpoints across the fleet matches the zone distribution.
Step 5 — Drill the zone-loss case
# Take zone A's replicas out and confirm spill-over rather than failure.
echo "disable server pg_read_zone_a/a1" | socat stdio /var/run/haproxy.sock
echo "disable server pg_read_zone_a/a2" | socat stdio /var/run/haproxy.sock
# Reads from zone A should continue, now served by b1 and c1.
echo "show stat" | socat stdio /var/run/haproxy.sock | grep pg_read_zone_a
# Restore.
echo "enable server pg_read_zone_a/a1" | socat stdio /var/run/haproxy.sock
echo "enable server pg_read_zone_a/a2" | socat stdio /var/run/haproxy.sockInline verification: during the drill, zone A’s read error rate stays at zero and its p99 latency rises by roughly one cross-zone round trip — the expected, acceptable degradation.
Configuration Snippet
# Complete per-zone backend set. One backend per zone; each lists its own
# replicas as primary and the others as backups.
global
log stdout format raw local0
defaults
mode tcp
timeout connect 3s
timeout client 30m
timeout server 30m
option tcplog
listen pg_read_a
bind 0.0.0.0:6432
balance leastconn
option pgsql-check user haproxy_check
option allbackups
default-server inter 2s fall 3 rise 5 slowstart 60s maxconn 120
server a1 10.0.1.11:5432 check weight 100
server a2 10.0.1.12:5432 check weight 100
server b1 10.0.2.11:5432 check weight 100 backup
server c1 10.0.3.11:5432 check weight 100 backup
listen pg_read_b
bind 0.0.0.0:6433
balance leastconn
option pgsql-check user haproxy_check
option allbackups
default-server inter 2s fall 3 rise 5 slowstart 60s maxconn 120
server b1 10.0.2.11:5432 check weight 100
server b2 10.0.2.12:5432 check weight 100
server a1 10.0.1.11:5432 check weight 100 backup
server c1 10.0.3.11:5432 check weight 100 backupRun the proxy itself in each zone rather than centrally. A single central proxy re-introduces the cross-zone hop it was deployed to remove — every read then crosses from the application to the proxy’s zone and possibly back again. Deploying one proxy per zone, alongside the applications it serves, is what makes the local path genuinely local.
The maxconn 120 per server is doing double duty here. Beyond bounding connection use, it means a zone-outage spill-over cannot open unbounded connections against the remaining zones’ replicas — the excess queues at the proxy where it is visible and bounded, instead of exhausting max_connections on a replica that is now serving two zones.
Verification and Rollback
Confirm reads are actually staying local:
-- On each replica: which subnets are its clients in?
SELECT client_addr, count(*) AS conns
FROM pg_stat_activity
WHERE backend_type = 'client backend'
GROUP BY client_addr ORDER BY conns DESC;Compare the client addresses against your zone subnet allocations. A replica in the 10.0.1.0/24 (zone A) subnet should be serving almost exclusively 10.0.1.0/24 clients under normal conditions.
Confirm the cost actually fell:
# Compare the inter-AZ transfer line item month over month.
aws ce get-cost-and-usage \
--time-period Start=2026-06-01,End=2026-08-01 --granularity MONTHLY \
--metrics UnblendedCost \
--filter '{"Dimensions":{"Key":"USAGE_TYPE_GROUP","Values":["EC2: Data Transfer - Inter AZ"]}}'Allow a full billing period. Partial-month comparisons are misleading because transfer volume tracks traffic, which is not uniform across a month.
Rollback. Reverting is a config change with no data implications:
# Immediate: promote the backups to normal servers at runtime so traffic
# spreads across all zones again.
echo "set server pg_read_a/b1 weight 100" | socat stdio /var/run/haproxy.sock
# Permanent: remove the `backup` keyword from the remote server lines and
# reload, or point every application at a single shared backend.
systemctl reload haproxyRevert if per-zone replica load turns out higher than projected, or if a zone’s replicas prove unable to carry their zone alone. Both symptoms appear as rising p99 latency confined to one zone, which is a distinctive and easily-alertable shape.
Edge Cases and Gotchas
Autoscaling changes the zone distribution under you
The capacity check in step 2 is a snapshot. An autoscaler that adds instances wherever capacity is cheapest will drift the zone distribution over time, and the zone that was at 45% may reach 60% without anyone changing a configuration. Alert on per-zone application instance share, not only on replica utilisation, so the drift is visible before it becomes a capacity event.
A zone’s replicas can be “up” and unusable
Zone preference interacts with the freshness gate: if zone A’s replicas are all healthy but all lagging past the budget, the backup mechanism does not engage, because HAProxy sees the local servers as up. The spill-over you want in that case has to come from the freshness gate’s own fallback, not from the zone configuration. Make sure the two mechanisms are aware of each other — the lag agent’s drain verdict is what makes a lagging local replica look “down” to HAProxy and thereby trigger the backup tier.
Cross-region is not the same problem
The backup pattern works within a region, where the fallback costs a millisecond. Applying the same shape across regions makes the fallback cost tens of milliseconds per query plus cross-region transfer at a much higher rate — a spill-over that could be more damaging than a brief read outage. Cross-region topologies need an explicit decision about whether falling back is even desirable, which is covered in designing multi-region read replica topologies.
FAQ
How much does cross-zone database traffic actually cost?
It is charged per gigabyte in both directions on most major providers, and read result sets are usually the largest database traffic component by volume. The figure worth computing is your own: multiply the measured bytes returned by read queries per month by the per-gigabyte cross-zone rate. For a read-heavy service returning tens of terabytes a month it is frequently a larger line item than the replica instances themselves; for a small service it is negligible — measure before optimising.
Does zone-aware routing hurt load balance?
Yes, deliberately. It makes traffic follow your application tier’s zone distribution rather than spreading evenly, so a zone hosting 40% of your application instances sends 40% of the reads to its local replicas. That is acceptable only if each zone’s replicas can absorb their zone’s share, which is a capacity question to answer before enabling it rather than after.
What is the latency benefit of staying in-zone?
Typically a few hundred microseconds to about a millisecond of round-trip time per query. That sounds negligible until you count queries: a request that issues thirty sequential reads pays it thirty times, so one millisecond becomes thirty on the request’s critical path. The benefit is proportional to how chatty your read path is — large for ORM-heavy code, small for services that issue one aggregate query.
Should the primary also be zone-aware?
No — there is only one primary, so writes cross zones whenever the caller is not in its zone and nothing can be done about that short of moving the primary. Zone awareness applies only to the read path, where multiple equivalent targets exist. Attempting to co-locate application instances with the primary to avoid it creates a much worse dependency on that one zone.
Related
← Back to Load Balancing Reads Across a Replica Pool
- Lag-Aware Replica Weighting with HAProxy Agent Checks — what makes a lagging local replica trigger the backup tier.
- Step-by-Step Guide to Setting Up Cross-AZ Read Replicas — placing the replicas this routing depends on.
- Weighted vs Least-Connections Balancing for Read Replicas — the algorithm running inside each zone’s backend.