What is cross-AZ data transfer cost and how is it billed?
An availability zone (AZ) is an isolated group of data centers inside a region. Inter-zone traffic is any packet that leaves an instance, pod, or managed endpoint in one AZ and lands in another AZ of the same region. It never leaves the region or touches the public internet, but on AWS it is still metered.
How it is billed
AWS data transfer between AZs is charged per gigabyte. For traffic between EC2-based resources, the charge commonly applies in both directions: the sending side pays for data out and the receiving side pays for data in. A single request/response round trip between a client in zone A and a server in zone B is therefore billed twice. The request bytes are charged leaving A and arriving in B, and the response bytes are charged leaving B and arriving in A. Responses are usually much larger than requests, so the response leg tends to dominate.
Pricing differs between services. Some managed services bill cross-zone traffic differently or not at all, and AWS has changed these rules over time. Do not hard-code a per-GB figure into your planning. Check the current rate on your provider's pricing page, then compare it against the data transfer line items on your actual bill.
Why it happens by accident
Teams rarely choose to send traffic across zones. They deploy replicas in three AZs for availability and rely on the default load balancing behavior, such as a Kubernetes Service, a load balancer with cross-zone balancing enabled, or a client-side round-robin. These defaults spread requests evenly across healthy endpoints and ignore which zone each endpoint is in. With replicas spread evenly across three zones, roughly two out of every three requests cross a zone boundary. Nobody designed that cost. It comes from optimizing for even distribution without considering location.
How does zone-local routing work?
Zone-local routing makes the routing layer aware of location. Whatever picks the destination for a request, whether kube-proxy, a mesh sidecar, a client library or a load balancer, takes the caller's zone into account and prefers endpoints in that same zone. Request and response bytes then stay inside one AZ and are not billed as inter-zone transfer.
It needs three ingredients:
- Zone metadata on both ends. The router has to know which zone the caller is in and which zone each endpoint is in. In Kubernetes this comes from the
topology.kubernetes.io/zonelabel on nodes, which pods inherit through their node. Outside Kubernetes it usually comes from instance metadata or service registry tags. - A selection rule that prefers local endpoints. Instead of choosing uniformly from every healthy endpoint, the router filters or weights the candidate list toward same-zone endpoints. Kubernetes does this with zone hints on EndpointSlices. Meshes and client libraries do it with locality-aware load balancing.
- A fallback rule. When a zone has no healthy local endpoints, or too few to carry its share of load, traffic has to spill to other zones. Each mechanism defines this differently. Some fall back only when local endpoints disappear, others when the healthy share drops below a threshold. That rule determines how the system behaves during a partial zone failure.
Placement matters as much as routing. Zone-local routing only stays in place when every zone has enough replicas to serve its own clients, so it is paired with spreading replicas evenly, or in proportion to per-zone demand, across zones. The later sections show this for Kubernetes and for the alternatives.
How much does default load balancing cost? An illustrative estimate
That ratio follows from simple probability. If a caller picks uniformly among replicas spread evenly across three zones, the chance the chosen replica shares its zone is 1/3, so the cross-zone fraction is f = 2/3. Billing then counts each byte twice. Providers that charge for inter-zone traffic, including AWS data transfer between Availability Zones, typically bill the sending side for egress and the receiving side for ingress.
The monthly cost formula is:
Cost = requests/s × (request bytes + response bytes) × seconds/month × f × 2 × R
R is your provider's per-GB, per-direction rate. Look it up rather than trusting a remembered figure.
Illustrative estimate (assumed numbers, not a benchmark). These inputs are assumed:
- 30-day month (2,592,000 s)
- 2 KB request plus 8 KB response, so 10 KB per call
- f = 2/3
- R = $0.01/GB, chosen for illustration
| Scenario (assumed) | Internal calls/s | GB moved/month | Cross-zone GB | Billed GB (×2) | Cost at R = $0.01 |
|---|---|---|---|---|---|
| 20-service mesh, high throughput | 50,000 | 1,296,000 | 864,000 | 1,728,000 | ~$17,280 |
| 5-service app, light traffic | 200 | 5,184 | 3,456 | 6,912 | ~$69 |
Cost scales linearly with call rate and payload size. A chatty, high-throughput fleet can spend a significant sum every month on traffic that serves no purpose, so zone-local routing deserves a design review there.
For the small fleet, the savings would be cancelled out by a few hours of engineering time. The added operational risk would be another cost. Run the formula with your own traffic numbers before you decide to change anything.
Who should use zone-local routing?
Zone-local routing is worth the effort for teams that meet most of these conditions:
- They run multi-AZ within a single region. Single-AZ deployments have no inter-zone traffic to remove, and multi-region traffic is a separate problem with different pricing.
- Internal traffic volume is high. Microservice fleets with many service-to-service calls, large response payloads, read-heavy database or cache clients, and heavy telemetry pipelines are the usual candidates. The formula above shows cost scales with bytes, so byte volume is what qualifies a workload.
- The inter-AZ line item is material on the bill. If the data transfer between zones is a small fraction of spend, the engineering and operational cost of changing routing will likely exceed the savings.
- Replicas already exist in every zone. Zone-local routing needs local endpoints to route to. Services with one or two replicas cannot give every zone its own copy.
- Per-zone demand is reasonably even, or can be matched with per-zone capacity. If most clients sit in one zone, keeping their traffic local means that zone's replicas carry most of the load.
The work typically falls to platform or infrastructure teams who own the cluster, mesh and load balancer configuration, with input from whoever owns cloud cost reporting. Application teams are involved mainly when a fix needs code changes, such as switching a client library to locality-aware balancing or reading from a nearest replica.
Teams that should hold off: small fleets with light traffic, workloads where cross-zone traffic is almost all durability replication, and services whose availability requirements cannot tolerate the failure modes described in the final section.
Where does inter-zone traffic actually come from?
Before you run that formula, you need to know which flows are producing the bytes. In most clusters, inter-zone traffic comes from a small set of sources:
Zone A Zone B Zone C
+--------------+ +--------------+ +--------------+
| frontend ----+-(1)-> api pod | | api pod |
| LB node -----+-(2)-------------------------> frontend |
| DB primary --+-(3)-> DB replica | | |
| broker 1 ----+-(4)-> broker 2 ----+-(4)-> broker 3 |
| agent -------+-(5)-------------------------> collector |
+--------------+ +--------------+ +--------------+
- Service-to-service calls. kube-proxy and most service meshes spread requests evenly across all ready endpoints by default. With replicas in three zones, about two-thirds of calls leave the caller's zone.
- Load balancers with cross-zone distribution. A load balancer node in one zone forwards to targets in every zone, which doubles the hops that can cross a boundary.
- Database reads and writes. An application that writes to a primary in another zone pays on every query and every result set. Synchronous replication to standbys in other zones adds more.
- Stateful replication. Kafka partition replicas, Elasticsearch-style shard copies and database replicas copy every write to other zones by design.
- Telemetry shipping. Logs, metrics and traces sent to a single central collector cross zones continuously. The volume often grows faster than request traffic.
These flows fall into two groups. Flows 3 (replication) and 4 exist for durability. Placing copies in other zones is the reason they survive a zone failure, so you pay for that traffic on purpose and should size it, not remove it. Flows 1, 2, 5 and cross-zone application reads to the database exist mostly because of routing defaults and collector placement. That second group is where zone-local routing saves money.
Zone A Zone B Zone C
+------------------+ +------------------+ +------------------+
| | | | | |
| LB node ---------+[R]+------------------+[R]+-> frontend |
| frontend --------+[R]+-> api pod | | |
| DB primary <-----+[R]+------------------+[R]+-- api pod |
| | | | | |
| DB primary ------+[D]+-> DB replica | | |
| broker 1 (ldr) +-+[D]+-> broker 2 | | |
| +-+[D]+------------------+[D]+-> broker 3 |
| | | | | |
| metrics agent ---+[R]+------------------+[R]+-> collector |
| | | | | |
+------------------+ +------------------+ +------------------+
[R] routing default: avoidable with zone-aware routing and placement
[D] durability: cross-zone on purpose; size it, don't remove it
Three-zone deployment showing which flows cross zone boundaries: [D] flows (database and broker replication) are paid for durability, while [R] flows (load balancer fan-out, service calls, remote database queries and telemetry shipping) cross zones only because of routing and placement defaults.
How do you measure inter-zone traffic before changing anything?
Start with the bill, because it tells you whether the problem is big enough to chase. In Cost and Usage Report data, intra-region transfer between zones appears under usage types containing DataTransfer-Regional-Bytes. Filter to those, group by resource and day, and you get a total and a trend. The bill does not tell you which workload talked to which, so it cannot point you at a fix.
For attribution, use VPC flow logs. Build a lookup table from each subnet's CIDR to its subnet ID and Availability Zone. Then add a second mapping from IP address to workload, using ENI descriptions, pod IP records or instance tags.
The query, shown separately, joins each flow record's source and destination addresses to that table. It keeps rows where the source zone differs from the destination zone, sums bytes by workload pair, and sorts descending. Deduplicate before summing. A flow between two monitored interfaces is logged at both ends, and counting it twice inflates the paths you care about most.
Rank by bytes, not by request count. Database replication, bulk reads from object caches and telemetry shipping usually sit at the top. A chatty RPC path with thousands of small calls per second often moves little data, so rerouting it saves almost nothing.
Coarse measurement, such as per-cluster totals or sampled request traces, tends to surface exactly those chatty paths, and teams spend weeks optimizing the wrong one. A byte-ranked list typically shows that a handful of paths account for most of the spend. Fixing those few first means engineering time goes to changes that actually move the bill.
-- Conceptual query: rank workload pairs by cross-AZ bytes.
-- Assumed tables (built by you, names are placeholders):
-- flow_logs(interface_id, srcaddr, dstaddr, bytes, action, start_time)
-- ip_inventory(ip, eni_id, workload, subnet_id) -- from ENI descriptions, pod IPs, tags
-- subnet_az(subnet_id, cidr, az) -- one row per subnet
WITH src_side AS (
-- Deduplicate: a flow between two monitored ENIs is logged at both ends.
-- Keep only the record captured on the sender's own interface.
SELECT f.srcaddr, f.dstaddr, f.bytes
FROM flow_logs f
JOIN ip_inventory s
ON s.ip = f.srcaddr
AND s.eni_id = f.interface_id
WHERE f.action = 'ACCEPT'
AND f.start_time >= DATE '2026-10-01'
AND f.start_time < DATE '2026-11-01'
)
SELECT
s.workload AS src_workload,
sa.az AS src_az,
d.workload AS dst_workload,
da.az AS dst_az,
SUM(r.bytes) / 1e9 AS cross_az_gb
FROM src_side r
JOIN ip_inventory s ON s.ip = r.srcaddr
JOIN subnet_az sa ON sa.subnet_id = s.subnet_id
JOIN ip_inventory d ON d.ip = r.dstaddr
JOIN subnet_az da ON da.subnet_id = d.subnet_id
WHERE sa.az <> da.az -- keep only cross-zone traffic
GROUP BY s.workload, sa.az, d.workload, da.az
ORDER BY cross_az_gb DESC -- rank by bytes, not request count
LIMIT 20;
A conceptual SQL query that deduplicates VPC flow log records to the sender's interface, maps source and destination IPs to workloads and Availability Zones, and ranks cross-zone workload pairs by total bytes.
How does Kubernetes topology aware routing work?
Topology aware routing applies the same principle to traffic inside the cluster: a byte that never leaves its zone is never billed as inter-zone traffic. In Kubernetes, the mechanism lives in EndpointSlices. When the feature is enabled on a Service, the EndpointSlice controller can attach a zone hint to each endpoint, recording which zone's clients should use it. kube-proxy, or a compatible dataplane that honours hints, then routes a client only to endpoints hinted for that client's zone instead of spreading requests across every ready pod in the cluster.
The controller does not always apply hints. If a Service has too few endpoints, or if allocatable capacity is unevenly spread across zones, it can decline to set them or remove existing ones. Traffic then falls back to cluster-wide distribution. The Service keeps working, and nothing fails visibly. The savings simply stop, so you need to check periodically whether hints are actually present rather than assuming they are.
The example manifest that follows enables the feature on an ordinary Service. The annotation or field that does this has changed name across Kubernetes releases, so check the documentation for your cluster's version before copying it.
Hints only hold if the zones stay balanced:
- Use topology spread constraints to keep replicas even across zones.
- Scale per zone where your tooling allows it.
- Size each zone so its local replicas can absorb local demand.
Without that headroom, zone-local routing creates hotspots, or the controller withdraws the hints altogether.
apiVersion: v1
kind: Service
metadata:
name: orders-api
annotations:
# Asks the EndpointSlice controller to add zone hints.
# Older releases used service.kubernetes.io/topology-aware-hints: auto.
# Newer releases also offer spec.trafficDistribution. Check your version's docs.
service.kubernetes.io/topology-mode: Auto
spec:
selector:
app: orders-api
ports:
- port: 80
targetPort: 8080
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: orders-api
spec:
replicas: 6 # enough endpoints for every zone to get its own share
selector:
matchLabels:
app: orders-api
template:
metadata:
labels:
app: orders-api
spec:
# Keep replica counts within 1 of each other across zones so the
# controller does not withdraw hints because of a zone imbalance.
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app: orders-api
containers:
- name: orders-api
image: registry.example.com/orders-api:1.0.0
ports:
- containerPort: 8080
An illustrative Service that requests zone hints, paired with a Deployment whose topology spread constraint keeps replicas balanced across zones so the hints stay in place. The annotation name varies by Kubernetes version, so check the documentation for yours.
What are the alternatives to Kubernetes topology aware routing?
Topology aware routing is one lever among several. Other options sit at different layers of the stack, so they can complement it or replace it.
- Locality-aware load balancing in a service mesh or client-side balancer. The proxy or client library prefers endpoints in its own zone. It fails over to other zones based on endpoint health and the share of endpoints still available. This gives finer control than Kubernetes hints, but you have to run and upgrade the mesh.
- Zone-local caches and read replicas. Repeated reads are served inside the zone, so they never cross a zone boundary. Writes still go to the primary, and you have to manage how stale replica reads can be.
- Nearest-replica fetching. Some brokers and databases let a consumer read from a follower in its own zone instead of the leader. Replication traffic still crosses zones, but fan-out reads no longer do.
- Disabling cross-zone distribution on load balancers. This only works when clients and targets are already spread evenly across zones. Otherwise the load balancer nodes in each zone forward traffic to unevenly sized target groups.
- Per-zone cells. Each zone runs a full, independent stack. This removes most service-to-service inter-zone traffic, at the cost of redesigning how you deploy and route.
| Option | Savings potential | Availability risk | Operational complexity | Per-zone headroom overhead | Development cost |
|---|---|---|---|---|---|
| Mesh / client-side locality | High | Low–medium | Medium–high | Medium | Low |
| Zone-local caches / replicas | Medium | Low | Medium | Medium | Medium |
| Nearest-replica fetching | High for read-heavy | Low | Low–medium | Low | Low |
| Load balancer cross-zone off | Medium | Medium–high | Low | High | Low |
| Per-zone cells | Highest | Low if well built | High | High | High |
Managed load balancers and data services add, rename and reprice routing features over time. Check the current provider documentation and pricing before you rely on any of these behaviours.
How can this approach save engineering time?
The main saving is in money, but the way you go about it decides how much engineering time it costs. Several parts of the approach keep that time low:
- Measure first, then fix only the top paths. The byte-ranked flow log query turns a vague "our transfer bill is high" into a short list of workload pairs. Engineers work on the few paths that dominate spend instead of auditing every service or rerouting chatty but low-volume RPC paths that barely affect the bill.
- Prefer configuration over code. Kubernetes topology aware routing is a Service annotation or field plus a topology spread constraint. Mesh locality settings are proxy configuration. Neither requires changes to application code, so one platform team can roll them out across many services without coordinating code changes with each owning team.
- Reuse the cost formula as a decision filter. Plugging a workload's call rate and payload size into the formula before starting work quickly rules out fleets where the savings would not cover the effort, such as the small fleet in the illustrative estimate. That avoids projects that cost more in engineer hours than they save.
- Separate durability traffic early. Classifying replication flows as intentional means nobody spends time trying to make database or broker replication zone-local, which would undermine the reason it exists.
- Automate detection of regressions. An alert on hint removal and a per-zone dashboard catch silent fallback to cluster-wide routing as it happens. Without them, the usual discovery path is a rising bill weeks later, followed by a fresh investigation to find what changed.
- Make the tooling reusable. The subnet-to-zone table, IP-to-workload mapping and query can be kept and rerun each billing period. Later cost reviews then start from an existing ranked report instead of rebuilding the analysis.
When should you avoid zone-local routing, and how do you verify it worked?
Zone-local routing trades cross-zone redundancy for cost. Know where that trade-off can hurt you before you enable it.
| Situation | Risk | What to do |
|---|---|---|
| Zone degraded but endpoints still pass readiness | Local clients stay pinned to slow or failing replicas, because fallback usually triggers only when endpoints are removed, not when they get slow | Read your mechanism's fallback rules and make readiness probes reflect real health |
| Uneven client traffic or replica counts per zone | One zone's replicas run hot while others sit idle | Spread replicas in proportion to per-zone demand, not just evenly |
| Autoscaling | Scale-up adds pods in the wrong zone, or scale-down leaves a zone short | Use topology spread constraints and watch per-zone counts after scaling events |
| Replication for durability (databases, queues) | Forcing locality on replication traffic defeats its purpose | Optimize only read paths and client locality, and leave replication cross-zone |
| Small fleets | Engineering and operational cost exceeds the savings | Skip it until the inter-AZ line item is material |
Verifying it worked
- Bill: Track the inter-AZ data transfer line item over several billing periods, not just one. Traffic growth can mask savings.
- Per-zone load and latency: Watch endpoint CPU, request rate and tail latency by zone. Skew shows up here first.
- Same-zone share: Compare the fraction of requests served in-zone before and after rollout, using flow logs or mesh telemetry.
- Hint removal: Alert when the controller stops publishing zone hints for a Service. Routing then quietly reverts to cluster-wide, and the bill rises again.
- Periodic review: Recheck current pricing and routing documentation, because both change.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to cross-AZ data transfer cost, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.