Discovering A and AAAA records provides possible destinations. It does not select a successful connection by itself. Address ordering, source-address selection, routing, and the application’s connection algorithm all contribute. RFC 6724 describes default source and destination selection rules, while applications can impose their own choices. A program requesting only IPv4 has already narrowed the result before any dual-stack connection strategy can act.

When it breaks

Address preference is not an application-health probe. An IPv6 destination can look suitable according to local policy and routing even when its remote service is stopped or a later network hop drops traffic. The preferred candidate may therefore fail. Assuming that the operating system has already tested every returned endpoint can hide the need for connection deadlines and alternate-address handling.

RFC 6724’s rules are ordered comparisons, not a general score for cheapest or fastest network path. They consider properties such as usability, scope, policy labels, precedence, and matching prefixes. They do not measure application response time. Destination address selection must also be distinguished from a routing table’s longest-prefix match for sending packets after a destination has been chosen.

Happy Eyeballs is another layer. It coordinates discovery and staggered connection attempts to reduce delays when one family is problematic. A client that tries addresses sequentially is not automatically implementing that algorithm. Immediate connection refusal and a silently dropped connection attempt can produce very different waiting times, even if both clients eventually reach the same working IPv4 service.

Check it quickly

Save this as check-address-selection.py and run python3 check-address-selection.py with Docker. The pinned environment uses glibc 2.36 and Python 3.11, with BIND 9.18.49 and dnsmasq 2.90 supplying DNS. Two real HTTP listeners use IPv4 and IPv6 loopback on the same port. The fixture has no public network path, so its observed order is explicitly a local result:

from pathlib import Path
from tempfile import TemporaryDirectory
import json,subprocess,uuid
recipe='FROM python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84\nRUN apt-get update && apt-get install -y --no-install-recommends bind9=1:9.18.49-1~deb12u2 bind9-dnsutils=1:9.18.49-1~deb12u2 dnsmasq-base=2.90-4~deb12u2 python3-dnspython=2.3.0-1 && rm -rf /var/lib/apt/lists/*\nCMD ["/usr/bin/python3"]\n'
program=r"""
from contextlib import ExitStack,contextmanager
from pathlib import Path
from tempfile import TemporaryDirectory
import json,re,signal,subprocess,time
import dns.exception,dns.message,dns.query,dns.rcode,dns.rdatatype

def run(*args,check=True):
    result=subprocess.run(args,capture_output=True,text=True,timeout=15)
    if check:assert result.returncode==0,(args,result.returncode,result.stdout,result.stderr)
    return result
def query(name,kind='A',port=1053):
    return dns.query.udp(dns.message.make_query(name,kind),'127.0.0.1',port=port,timeout=1)
def values(response,kind='A'):
    wanted=dns.rdatatype.from_text(kind)
    return [item.to_text() for rrset in response.answer if rrset.rdtype==wanted for item in rrset]
def show(response):
    def section(records):return [{'name':str(rr.name),'type':dns.rdatatype.to_text(rr.rdtype),'ttl':rr.ttl,'data':[item.to_text() for item in rr]} for rr in records]
    return {'rcode':dns.rcode.to_text(response.rcode()),'answer':section(response.answer),'authority':section(response.authority)}
def wait(predicate,timeout=15):
    deadline=time.monotonic()+timeout
    while time.monotonic()<deadline:
        try:
            if predicate():return
        except (OSError,dns.exception.Timeout):pass
        time.sleep(.05)
    raise AssertionError('DNS fixture condition did not become true')
def stop(process):
    if process.poll() is None:
        process.terminate()
        try:process.wait(timeout=5)
        except subprocess.TimeoutExpired:process.kill();process.wait(timeout=5)

@contextmanager
def lab(records,soa_ttl=8,minimum=4,extra=''):
    with TemporaryDirectory(prefix='xbcatm-dns-') as directory,ExitStack() as stack:
        root=Path(directory);zone=root/'zone';conf=root/'named.conf';log=root/'named.log';serial=1
        def save(body):
            zone.write_text(f'$ORIGIN lab.test.\n$TTL 30\n@ {soa_ttl} IN SOA ns.lab.test. hostmaster.lab.test. ({serial} 60 60 3600 {minimum})\n@ IN NS ns.lab.test.\nns IN A 127.0.0.1\n'+body+'\n')
            run('named-checkzone','lab.test',str(zone))
        save(records)
        conf.write_text(f'''options {{ directory "{root}"; listen-on port 1053 {{127.0.0.1;}}; listen-on-v6 {{none;}};
recursion no; dnssec-validation no; notify no; querylog yes; max-cache-size 8m; pid-file "{root}/named.pid"; session-keyfile none;
{extra} }};
controls {{}};
zone "lab.test" {{ type primary; file "{zone}"; }};
''')
        run('named-checkconf',str(conf))
        output=stack.enter_context(log.open('w'))
        authority=subprocess.Popen(['named','-g','-c',str(conf),'-n','1','-U','1'],stdout=output,stderr=subprocess.STDOUT)
        stack.callback(stop,authority)
        def ready():
            assert authority.poll() is None,log.read_text()
            response=query('lab.test.','SOA');return response.rcode()==0 and response.answer[0][0].serial==serial
        wait(ready)
        def update(body):
            nonlocal serial
            serial+=1;save(body);authority.send_signal(signal.SIGHUP);wait(ready)
        def cache(port=1054,upstream=1053,size=150,options=()):
            logfile=root/('cache-'+str(port)+'.log');stream=stack.enter_context(logfile.open('w'))
            command=['dnsmasq','--no-daemon','--conf-file=/dev/null','--no-resolv','--no-hosts','--bind-interfaces',
                '--listen-address=127.0.0.1','--port='+str(port),'--server=127.0.0.1#'+str(upstream),
                '--cache-size='+str(size),'--log-queries','--log-facility=-',*options]
            process=subprocess.Popen(command,stdout=stream,stderr=subprocess.STDOUT);stack.callback(stop,process)
            def cache_ready():
                assert process.poll() is None,logfile.read_text()
                return query('lab.test.','SOA',port).rcode()==0
            wait(cache_ready);return process,logfile
        def queries(name,kind='A'):
            return sum(bool(re.search(r'query: '+re.escape(name.rstrip('.'))+r' IN '+kind+r'\b',line)) for line in log.read_text().splitlines())
        yield root,update,cache,queries

@contextmanager
def named_process(root,config):
    root.mkdir(exist_ok=True);conf=root/'named.conf';log=root/'named.log';conf.write_text(config)
    run('named-checkconf',str(conf))
    with log.open('w') as output:
        process=subprocess.Popen(['named','-g','-c',str(conf),'-n','1','-U','1'],stdout=output,stderr=subprocess.STDOUT)
        try:
            def ready():
                assert process.poll() is None,log.read_text()
                return re.search(r'\brunning\s*$',log.read_text(),re.M) is not None
            wait(ready);yield process,log
        finally:stop(process)
from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer
import socket,threading
class Handler(BaseHTTPRequestHandler):
    def log_message(self,*args):pass
    def do_GET(self):
        body=self.server.server_address[0].encode();self.send_response(200);self.send_header('Content-Length',str(len(body)));self.end_headers();self.wfile.write(body)
class V6Server(ThreadingHTTPServer):address_family=socket.AF_INET6
with lab('dual 30 IN A 127.0.0.1\ndual 30 IN AAAA ::1') as (_,update,cache,queries):
    cache(port=53)
    Path('/etc/resolv.conf').write_text('nameserver 127.0.0.1\noptions timeout:1 attempts:1\n')
    nss=Path('/etc/nsswitch.conf');nss.write_text(re.sub(r'^hosts:.*$','hosts: dns',nss.read_text(),flags=re.M))
    servers=[];threads=[]
    try:
        for server_class,address in [(ThreadingHTTPServer,'127.0.0.1'),(V6Server,'::1')]:
            server=server_class((address,0 if not servers else servers[0].server_port),Handler)
            thread=threading.Thread(target=server.serve_forever,daemon=True);thread.start();servers.append(server);threads.append(thread)
        port=servers[0].server_port;v6=servers[1]
        def candidates(family=socket.AF_UNSPEC):return socket.getaddrinfo('dual.lab.test',port,family,socket.SOCK_STREAM)
        addresses=candidates();assert {r[0] for r in addresses}=={socket.AF_INET,socket.AF_INET6}
        assert addresses[0][0]==socket.AF_INET6
        assert {r[0] for r in candidates(socket.AF_INET)}=={socket.AF_INET}
        assert {r[0] for r in candidates(socket.AF_INET6)}=={socket.AF_INET6}
        def request():
            with socket.create_connection(('dual.lab.test',port),timeout=1) as stream:
                chosen={'family':stream.family.name,'peer':stream.getpeername()[0],'source':stream.getsockname()[0]}
                stream.sendall(b'GET / HTTP/1.0\r\nHost: dual.lab.test\r\n\r\n');data=b''
                while True:
                    part=stream.recv(4096)
                    if not part:break
                    data+=part
                assert b' 200 ' in data.split(b'\r\n',1)[0] and data.endswith(chosen['peer'].encode());return chosen
        first=request();assert first['family']=='AF_INET6'
        v6.shutdown();v6.server_close();assert values(query('dual.lab.test.','AAAA'),'AAAA')==['::1']
        with socket.socket(socket.AF_INET6) as forced:
            forced.settimeout(1)
            try:forced.connect(('::1',port));raise AssertionError('closed IPv6 listener accepted a connection')
            except OSError as error:failure=type(error).__name__
        assert candidates()[0][0]==socket.AF_INET6
        second=request();assert second['family']=='AF_INET'
        print(json.dumps({'candidate_order':[{'family':socket.AddressFamily(r[0]).name,'address':r[4][0]} for r in addresses],
            'both_listeners_healthy':first,'forced_IPv6_after_stop':failure,'sequential_client_after_IPv6_stop':second,
            'AAAA_still_published':True},sort_keys=True))
    finally:
        for server in servers:server.shutdown();server.server_close()
        for thread in threads:thread.join(timeout=2)
"""
name='xbcatm-dns-'+uuid.uuid4().hex[:10]
def docker(*args,**kwargs):return subprocess.run(['docker',*args],capture_output=True,text=True,timeout=300,**kwargs)
try:
    with TemporaryDirectory(prefix=name+'-') as directory:
        root=Path(directory);(root/'Dockerfile').write_text(recipe)
        built=docker('build','-t',name,str(root));assert built.returncode==0,built.stderr
        result=docker('run','--rm','-i','--name',name,'--network','none','--memory','384m','--pids-limit','128',name,
            '/usr/bin/python3','-',input=program)
        assert result.returncode==0,result.stderr
        print(json.dumps(json.loads(result.stdout),sort_keys=True))
finally:
    docker('rm','-f',name);docker('image','rm',name)

For dual.lab.test, getaddrinfo with AF_UNSPEC returns both ::1 and 127.0.0.1, with IPv6 first in the recorded environment. Separate calls restricted to AF_INET or AF_INET6 return only the requested family. These calls establish the actual candidate list used by this process rather than assuming that separate A and AAAA query outputs define a universal merged ordering.

With both listeners healthy, Python’s socket.create_connection connects to ::1. The program records both the selected peer and the local source address, then sends a real HTTP request and verifies the listener’s identifying response. This links candidate preference to an actual successful connection while keeping source address, destination address, and application response visible as distinct observations.

The IPv6 listener is then stopped and closed. DNS still publishes ::1, and getaddrinfo still puts IPv6 first. A forced IPv6 connection fails with ConnectionRefusedError. The normal Python connection helper nevertheless succeeds through 127.0.0.1 and receives the IPv4 listener’s response. Its sequential attempt behavior can reach a later working candidate without requiring the DNS order to change.

The refusal is immediate on this local path. The test does not inject a black hole, measure an internet fallback delay, or exercise a full Happy Eyeballs implementation. It demonstrates that local address preference can remain unchanged while service availability changes, and that the connection algorithm is responsible for turning that candidate list into an eventual success or failure.

Inspect the actual caller before changing host-wide policy

Check which family and flags the application asks getaddrinfo to use, whether its runtime caches results, and how it attempts the candidates. Some applications use different resolver libraries or implement their own connection racing. Reproducing a lookup with a command-line tool is useful only when the tool’s relevant choices match those of the failing process.

Observe selected source and destination addresses alongside connection timing and errors. A working DNS lookup followed by refusal suggests a different investigation from a timeout along the routed path. Test each advertised family through the actual service identity and protocol. A healthy IPv4 endpoint does not prove that the advertised IPv6 endpoint has matching routing, access rules, certificates, or application configuration.

Use policy changes deliberately when the deployment requires them, and verify their effect on real callers. RFC 6724 permits administrative policy, but that does not make a global preference change a substitute for repairing a broken advertised service. Likewise, removing AAAA changes future discovery while cached records and existing connections can persist. Plan those lifetimes alongside any endpoint change.

Treat the observed order as evidence for this host and process, not a guarantee for all clients. A complete diagnosis identifies the candidate set, local selection policy, attempted connections, and successful peer. Keeping those stages separate explains dual-stack behavior without inventing a promise that the first address is healthy or that every client races the two families.

Sources