IPv6 stopped being a future project some time ago. Mobile carriers in many countries run IPv6-only access networks, large content networks serve most of their traffic over it in some regions, and cloud providers now bill for public IPv4 addresses. Yet plenty of production services still publish only an A record, store client addresses in 15-character columns and rate-limit by exact address, which works fine until the day it does not.

This page is the practical companion to IPv6 architecture, in depth, which explains addressing plans, Neighbor Discovery and SLAAC. Here the question is narrower: what does an application or platform team have to change so that a service is reachable, correct and fair over IPv6? It ends with a worked rollout and the failures that appear after you publish an AAAA record.

Advertisement

Reading the 2026 adoption numbers

There is no single IPv6 adoption figure. Google publishes the share of users reaching it over IPv6, and on 28 March 2026 that figure touched 50.1% for one day, up from about 46% a year earlier. Cloudflare and APNIC, measuring different populations in different ways, reported roughly 40% and 43% at around the same time. Weekends run higher than weekdays, because home and mobile networks are better deployed than offices.

The spread matters more than the headline. National figures range from above 70% in countries such as France and India to below 20% in others, and within a country the split is by network type: mobile and large consumer broadband are mostly IPv6, enterprise offices and many hosting networks are not. The useful number is your own: the share of requests that would arrive over IPv6 if you published AAAA. For a mobile-heavy consumer app that is often the majority; for a B2B API called from data centres it may be small.

Why it reached your backlog: the IPv4 bill and the translator path

Two forces push IPv6 onto service teams. The first is cost. Since 1 February 2024 AWS charges $0.005 per hour for every public IPv4 address, attached or idle. That is about $3.65 per address per month; a fleet with 400 public addresses across load balancers, NAT gateways and instances pays roughly $1,460 a month for addresses alone.

The second is reachability you cannot see. An IPv6-only mobile client can still reach an IPv4-only service, because the carrier translates: either a DNS64 resolver synthesizes an AAAA record inside a NAT64 prefix such as 64:ff9b::/96 and a NAT64 gateway translates the packets, or the handset runs a CLAT that turns the app's IPv4 into IPv6 (the 464XLAT arrangement). It works, so nobody complains, but every such request crosses a stateful translator you do not operate and arrives from a carrier IPv4 address shared by thousands of subscribers.

Four ways a client in 2026 reaches your service, and what your server seesDual-stack homenative v6 and v4IPv6-only mobileCLAT on handsetIPv6-only + DNS64synthesized AAAAIPv4-only officeCGNAT or NAT44Carrier NAT6464:ff9b::/96 to IPv4Dual-stack load balancerAAAA and A publishedBackend fleetv6-only or dual-stackIPv6 wins Happy Eyeballsvia CLATDNS64IPv4 from poolIPv4, shared addressclient IPPublish AAAA and IPv6 clients arrive natively. Leave it out and the same clients still arrive,but through a carrier translator, from a shared IPv4 address you cannot rate-limit fairly.Your logs, allowlists and rate limits must handle both families and IPv4-mapped forms.
Clients arrive natively when you publish AAAA, and through carrier translators when you do not. Either way, your server must handle both address families.
Advertisement

Sockets: listening and connecting correctly

Most IPv6 bugs in application code are socket bugs. On the server side, a socket bound to the IPv6 wildcard :: may or may not also accept IPv4 connections, depending on the IPV6_V6ONLY option. Linux defaults it off (controlled by the net.ipv6.bindv6only sysctl), so one socket serves both families and IPv4 peers appear as IPv4-mapped addresses like ::ffff:198.51.100.7. Windows and the BSDs default it on, so you need two sockets or must clear the option explicitly. Reverse proxies have their own defaults, so read the listen directive rather than assuming: nginx, for example, treats listen [::]:443 as IPv6-only unless told otherwise, which is why configurations carry both a plain and a bracketed listen line.

On the client side the rule is simple: never pick the family yourself. Call getaddrinfo with AF_UNSPEC, try the returned addresses in order, and let the OS's address selection policy put IPv6 first when it is usable. Better still, use a client library that implements Happy Eyeballs (RFC 8305): it starts an IPv6 attempt, and if that has not connected within a short delay (the RFC recommends 250 ms) starts IPv4 in parallel and keeps whichever wins. Without it, a client with a broken IPv6 path waits for a full TCP timeout on every connection before falling back.

import socket

# Server: one socket that accepts both families where the OS allows it.
if socket.has_dualstack_ipv6():
    srv = socket.create_server(("", 8080), family=socket.AF_INET6, dualstack_ipv6=True)
else:
    srv = socket.create_server(("0.0.0.0", 8080))   # add a second socket for "::"

# Client: never hard-code a family. Try every address getaddrinfo returns, in order.
def connect(host, port, timeout=3.0):
    last = None
    for family, type_, proto, _, addr in socket.getaddrinfo(host, port, socket.AF_UNSPEC, socket.SOCK_STREAM):
        s = socket.socket(family, type_, proto)
        s.settimeout(timeout)
        try:
            s.connect(addr)
            return s
        except OSError as e:
            last = e
            s.close()
    raise last or OSError("no addresses")

The client function above is a sequential fallback, not Happy Eyeballs: correct, but slow when the first address blackholes. Many runtimes now ship Happy Eyeballs in their HTTP stacks; check yours, and see the Happy Eyeballs architecture for how the race works.

Address handling in application code

Once IPv6 traffic arrives, every place that touches a client address is a candidate bug. Columns sized for dotted quads truncate. Regular expressions that validate addresses reject valid ones. URL builders that write http://{host}:{port} produce nonsense; IPv6 literals in URLs need brackets, as in https://[2001:db8::1]:8443/. Link-local addresses carry a zone index such as fe80::1%eth0 that most parsers do not expect. And the same address has many textual forms, so equality comparisons on strings fail unless every writer uses the canonical form from RFC 5952 (lowercase, longest zero run compressed).

The fix is to parse addresses with a real library at the edge, store them in a native type (16 bytes, or a database type such as PostgreSQL's inet), convert IPv4-mapped addresses back to IPv4 so the same user does not appear under two identities, and only ever write the canonical text form.

import ipaddress

def client_key(raw, v6_prefix=64):
    """Canonical address and rate-limit bucket for a peer address string."""
    ip = ipaddress.ip_address(raw.split("%")[0])          # drop a zone id such as %eth0
    if ip.version == 6 and ip.ipv4_mapped:                 # ::ffff:198.51.100.7 from a dual-stack socket
        ip = ip.ipv4_mapped
    if ip.version == 4:
        return str(ip), str(ip)                            # bucket per IPv4 address
    net = ipaddress.ip_network(f"{ip}/{v6_prefix}", strict=False)
    return ip.compressed, str(net)                         # bucket per /64 (or /56)

assert client_key("::ffff:198.51.100.7") == ("198.51.100.7", "198.51.100.7")
assert client_key("2001:DB8:0:0:1:0:0:1")[0] == "2001:db8::1:0:0:1"
assert client_key("2001:db8:aa:bb:1::5")[1] == "2001:db8:aa:bb::/64"

Rate limits, allowlists and abuse controls by prefix

An IPv4 address is roughly one household or one NAT. An IPv6 address is not a useful identity at all: a single home network is normally given a /56 or /64, hosts pick new random interface identifiers for privacy every day or so, and any customer of a hosting provider can use millions of addresses from their /64. A rate limiter keyed on the full 128-bit address is trivially evaded by rotating the low bits, and a blocklist of individual IPv6 addresses grows without bound.

Key IPv6 limits on prefixes instead. A /64 is the minimum sensible unit, since it is one LAN. Add a coarser second tier at /56 or /48 so that one subscriber cycling through their 256 /64s still hits a ceiling. Allowlists for partners should be expressed as prefixes the partner commits to, not addresses observed in logs. The flip side applies to IPv4: when IPv6-capable mobile users reach you through a carrier NAT64, hundreds of them share one IPv4 address, and a per-address IPv4 limit punishes all of them for one abuser. Publishing AAAA moves those users onto addresses you can tell apart, which is an underrated argument for doing it.

SignalIPv4 meaningIPv6 meaningKey on
One addressone host or one NATone interface, often short-livedIPv4: address. IPv6: never alone
/64not applicableone LAN or one VM allocationfirst IPv6 tier
/56 or /48not applicableone subscriber or one sitesecond IPv6 tier
Carrier NAT addressthousands of usersnot applicable once AAAA is publishedloose limits, plus account-level limits

Cloud and Kubernetes settings that decide reachability

In a cloud VPC, IPv6 is usually opt-in per network and per subnet. On AWS you associate an IPv6 block with the VPC, give each subnet a /64, add a route for ::/0 to the internet gateway for public subnets or to an egress-only internet gateway for private ones, and update every security group and network ACL, because IPv4 rules do not apply to IPv6 traffic. With no NAT hiding private instances, the egress-only gateway and security groups are the boundary. AWS also supports IPv6-only subnets, with DNS64 on the VPC resolver and NAT64 on a NAT gateway for reaching IPv4-only destinations.

In Kubernetes, dual-stack is a cluster setting (pod and service CIDRs for both families), and then a per-Service choice through ipFamilyPolicy and ipFamilies. Pods get one address per family. Managed offerings differ in whether they support IPv6-only or dual-stack clusters, and the load balancer controller must be told to provision a dual-stack frontend. Container runtimes often have IPv6 off on their default bridge networks, so local and CI environments silently test IPv4 only.

apiVersion: v1
kind: Service
metadata:
  name: api
spec:
  ipFamilyPolicy: PreferDualStack     # SingleStack | PreferDualStack | RequireDualStack
  ipFamilies: [IPv6, IPv4]            # first entry becomes the primary clusterIP family
  selector: {app: api}
  ports: [{port: 443, targetPort: 8443}]

Worked example: making a public API reachable over IPv6

A team runs an HTTPS API behind a cloud load balancer, with application servers in private subnets and a NAT gateway for outbound calls. Logs show a large share of traffic from mobile carriers. The rollout takes five steps, each reversible.

  1. Inventory. Search the code base and configuration for IPv4 assumptions: address columns, regexes, 0.0.0.0 binds, AF_INET, per-address rate limits and partner allowlists. Here, two columns were VARCHAR(15) and the rate limiter keyed on the full address.
  2. Fix the code first. Widen the columns, adopt a normalization function like the one above in the logging and rate-limit middleware, and add the /64 and /56 tiers. Deploy this while traffic is still IPv4 only.
  3. Dual-stack the edge only. Enable a dual-stack frontend on the load balancer and open IPv6 in its security group. The backends stay IPv4; the balancer terminates IPv6 and forwards the client address in a header, which the normalized middleware reads.
  4. Publish AAAA with a short TTL. Set a 300-second TTL so rollback is fast, then watch the IPv6 share, error rate and latency by family. In this case about 38% of requests moved to IPv6 within a day, and the top carrier IPv4 addresses, each previously shared by many users, dropped out of the rate-limit hit list.
  5. Go further service by service. IPv6 inside the VPC and IPv6-only subnets cut IPv4 spend, but need every dependency reachable over IPv6 or NAT64.
dig +short AAAA api.example.com            # is the record published?
curl -6 -sS -o /dev/null -w '%{http_code} %{remote_ip}' https://api.example.com/healthz
curl -4 -sS -o /dev/null -w '%{http_code} %{remote_ip}' https://api.example.com/healthz
ping -6 -M do -s 1452 api.example.com      # 1452 + 48 bytes of headers = 1500; does a full-size packet pass?
ss -ltn | grep 8080                        # [::]:8080 alone may or may not cover IPv4: check v6only
sysctl net.ipv6.bindv6only                 # Linux default 0: an AF_INET6 wildcard also accepts IPv4

Failure modes after you publish AAAA

  • Broken IPv6 path, healthy IPv4. Clients with Happy Eyeballs fall back after a few hundred milliseconds and you see a latency bump; clients without it time out. Monitor reachability per family from outside, not just the balancer health check, which is often IPv4 only.
  • Filtered ICMPv6. IPv6 routers do not fragment, so Path MTU Discovery depends on ICMPv6 Packet Too Big messages. A firewall that drops ICMPv6 breaks large responses while handshakes succeed, especially across tunnels. Allow the ICMPv6 types RFC 4890 recommends; Path MTU Discovery explains the blackhole pattern.
  • A wide-open second front door. Firewalls written for IPv4 may leave IPv6 unrestricted. Review both families together.
  • Identity split. The same user appears as an IPv4 address, an IPv4-mapped IPv6 address and native IPv6 addresses, breaking sessions pinned to client IP, fraud scores and geolocation. Normalize, and do not bind sessions to addresses.
  • IPv4 literals in configuration. Software that connects to 203.0.113.10 directly cannot use DNS64, so it fails in an IPv6-only network unless a CLAT is present. Use names.
  • Untested CI. Builds pass on IPv4-only container networks. Run one test environment IPv6-only.

Operational guidance

Treat IPv6 as a first-class dimension in observability: split request rate, error rate and latency by address family on every dashboard, and alert on the IPv6 share dropping suddenly, which usually means a route, firewall or DNS change broke it. Keep the DNS name for both families identical; separate v6. hostnames hide problems. For outbound dependencies, prefer names over literals so DNS64 works in IPv6-only networks, and for DNS behaviour see DNS in 2026. For translated clients that still arrive over IPv4, the NAT deep dive explains why carrier NAT state, not your servers, is often what fails.

What to do next

  1. Measure the share of your users on IPv6-capable networks from client-side telemetry or your CDN's logs.
  2. Grep the code and schema for IPv4 assumptions: column widths, regexes, AF_INET, 0.0.0.0 binds and per-address limits.
  3. Adopt one address normalization function and use it in logging, rate limiting, allowlists and analytics.
  4. Change IPv6 rate limits and blocklists to prefix keys at /64 with a /56 or /48 tier.
  5. Enable a dual-stack frontend, open IPv6 in security groups deliberately, and publish AAAA with a short TTL.
  6. Add per-family dashboards and an external IPv6 reachability probe, and allow the required ICMPv6 types.
  7. Run one CI or staging environment IPv6-only, and count your public IPv4 addresses to know what the migration is worth.
Key takeaway: IPv6 now carries a large and growing share of traffic, unevenly by country and network type, and public IPv4 addresses cost real money. Services that publish only A records are still reached by IPv6-only clients, but through carrier translators and shared addresses. Making a service IPv6-ready is mostly application hygiene: dual-stack sockets, Happy Eyeballs in clients, canonical address handling, prefix-based rate limits and explicit security rules for both families, followed by a dual-stack edge, an AAAA record with a short TTL and per-family monitoring.