Skip to content
Networking4 min read

A 200 millisecond penalty is what a completely broken IPv6 path looks like

Happy Eyeballs means a dead address family costs you latency instead of errors. The fixed delay on dual-stack destinations is a specific enough fingerprint to diagnose from, and it can sit unnoticed indefinitely.

· Venerable Networks

Enable IPv6 on a VPC and you touch four things. Three of them announce themselves.

| Step | Visible where | | --- | --- | | Associate an IPv6 CIDR with the VPC | VPC console, obvious | | Assign an IPv6 CIDR to the subnet | Subnet console, obvious | | Auto-assign IPv6 addresses on launch | Subnet setting, and visible on every instance | | Add a ::/0 route | Route table, IPv6 rows, easy to miss |

Skip the fourth and every visible surface still says IPv6 is on. Instances have global addresses. ip -6 route shows a default route. The console shows IPv6 CIDRs on the VPC and the subnet.

And IPv6 does not work off the VPC.

Why it times out rather than failing fast

This detail matters for diagnosis. Instances receive an IPv6 default route from the VPC router by router advertisement, independently of what the VPC route table contains. So the guest kernel believes it has full IPv6 connectivity and sends the packets.

They are discarded at the VPC route table, where no ::/0 route matches. The symptom is a timeout, not Network is unreachable. If the guest had no route, you would get the immediate error and the cause would be obvious. Because it has one, the failure looks like something out on the network.

A related trap: associating an IPv6 CIDR creates a local route for that prefix automatically. So the route table genuinely does have an IPv6 row in it, which makes it look configured, and IPv6 works fine between instances in the VPC. Only leaving fails.

The part that hides it

Most destinations are dual-stack, and clients implement Happy Eyeballs. curl resolves both families, starts the IPv6 attempt first because AAAA is preferred, waits out its happy-eyeballs timeout — 200 ms by default — then starts IPv4 in parallel and uses whichever connects first.

So instead of failing, every dual-stack destination gets slower by roughly a fixed amount:

curl -s -o /dev/null -w 'default: %{time_total}s\n' https://www.cloudflare.com/
# default: 0.462s
 
curl -4 -s -o /dev/null -w 'ipv4:    %{time_total}s\n' https://www.cloudflare.com/
# ipv4:    0.241s

That gap is the fingerprint. A fixed latency penalty of roughly the client's happy-eyeballs timeout, appearing on dual-stack destinations only, is a broken IPv6 path. It is 200 ms in curl by default and around 250 ms in many browsers.

Only IPv6-only destinations fail outright. So a completely non-functional address family can sit behind a working one indefinitely, taxing every connection, until some dependency happens to publish AAAA records only.

Localising it in two commands

The useful pair is inside versus outside the VPC:

# To another instance in the same VPC
ping6 -c 3 2600:1f18:abcd:1201::10
# 3 packets transmitted, 3 received, 0% packet loss
 
# Off the VPC
ping6 -c 3 -W 3 2606:4700:4700::1111
# 3 packets transmitted, 0 received, 100% packet loss

An address family that works locally and not remotely points at exactly one place, and it eliminates the interface, the addressing, and the security group in one step.

Then read the route table's IPv6 rows specifically, because the IPv4 rows will look complete:

aws ec2 describe-route-tables --route-table-ids "$RTB" \
  --query 'RouteTables[0].Routes[].[DestinationCidrBlock,DestinationIpv6CidrBlock,GatewayId,State]' \
  --output table

The audit query

The interesting state is a route table with an IPv4 default and no IPv6 default, in a VPC that has IPv6:

aws ec2 describe-route-tables --filters "Name=vpc-id,Values=$VPC" \
  --query 'RouteTables[?
      Routes[?DestinationCidrBlock==`0.0.0.0/0`] &&
      !Routes[?DestinationIpv6CidrBlock==`::/0`]
    ].RouteTableId' --output text

Anything that prints is a subnet where IPv6 is assigned and unroutable.

The same asymmetry applies to network ACLs, which hold IPv4 and IPv6 rules as separate sets. A NACL hardened for IPv4 only will admit IPv6 traffic it was never intended to — which is the more alarming version of this problem, because it fails open rather than closed.

Egress-only internet gateways are free

For a private subnet the IPv6 equivalent of NAT is an egress-only internet gateway, and it costs nothing. No hourly charge, no data processing charge.

There is no NAT Gateway for IPv6 and none is needed. Every IPv6 address is globally routable, so the requirement is not translation but preventing inbound connections, which is exactly what an egress-only internet gateway does.

This is one of the genuinely nice properties of dual-stack, and it is worth saying plainly because teams reach for a NAT Gateway out of habit when they turn IPv6 on: the expensive component on the IPv4 side has a free counterpart on the IPv6 side. A workload that can prefer IPv6 for its egress is a workload paying less for it.

Monitor the family you enabled

Nothing in an account with this misconfiguration will alarm. Add a synthetic check that forces the family:

curl -6 -s -m 5 -o /dev/null https://ipv6.google.com/ \
  || echo "FAIL: IPv6 egress is broken"

Otherwise the only signal is a dependency that happens to be IPv6-only, arriving whenever it arrives.

There is a lab that deploys this exact state, including the latency symptom being dismissed as noise in the ticket: the half of the internet that was missing. The VPC guide covers how route evaluation works, which is where the two families being independent stops being surprising.