Why architecture matters here
The reason a dedicated algorithm is needed is that the naive alternatives each fail in a specific, user-visible way. Strict sequential fallback — try IPv6, and only on failure try IPv4 — is correct but catastrophically slow when IPv6 fails by timeout rather than by an immediate refusal. A black-holed IPv6 path does not send a reset; the SYN simply disappears, and the client waits out its full connection timeout, commonly 20–75 seconds, before even beginning the IPv4 attempt. One broken protocol turns every connection into a minute-long stall, which users experience as 'the internet is down.'
The opposite naive fix, always use IPv4, is fast and reliable today but abandons IPv6 entirely, which is a dead end: the address space exhaustion that drove IPv6 does not go away, and a client that never prefers IPv6 gives networks no reason to deploy it well. And fully parallel, simultaneous attempts on both families — fire IPv6 and IPv4 SYNs at the same instant — is fast but wasteful and biased toward IPv4: it doubles connection load on servers and load balancers, and because IPv4 paths are often slightly lower-latency in partially-deployed networks, the simultaneous race tends to pick IPv4 even when IPv6 would have worked fine, quietly undermining the IPv6 preference.
Happy Eyeballs threads the needle with a staggered race. By starting IPv6 first and IPv4 only after a short 'connection attempt delay,' it gives IPv6 a head start so that on a healthy network IPv6 wins without IPv4 ever being launched — preserving the preference and avoiding the redundant IPv4 connection entirely. Yet the delay is short enough (a few hundred milliseconds) that if IPv6 is broken, the user waits only that brief interval before IPv4 kicks in, rather than a full timeout. The stagger is the whole trick: long enough to let healthy IPv6 win uncontested, short enough that broken IPv6 costs almost nothing.
There is a subtler problem the algorithm also has to solve: DNS resolution itself is dual and can stall. A client needs both the AAAA and A records, and if it waits for both queries to complete before doing anything, a slow or missing AAAA response delays even the IPv4 attempt. Happy Eyeballs specifies that resolution and connection be overlapped and that the client not block indefinitely on one address family's DNS answer — it starts connecting as soon as it has an address to try, and incorporates the other family's addresses as they arrive. The algorithm is thus as much about not stalling on resolution as it is about not stalling on connection.
Framed architecturally, Happy Eyeballs is a small control loop whose objective is to minimize time-to-first-established-connection subject to a soft preference for IPv6 and a hard constraint of not flooding the destination with redundant attempts. Every parameter — the resolution ordering, the attempt delay, the interleaving of multiple addresses, the caching of past winners — is a knob on that objective. Seeing it as an optimization with an explicit cost function (latency) and explicit constraints (preference, politeness) is what makes the otherwise fiddly details cohere into a single, tunable design rather than a bag of heuristics.
The architecture: every piece explained
Follow the stages. getaddrinfo (or its asynchronous equivalent) issues both the AAAA and A DNS queries. A key rule lives here: the client should not serialize these or block on both completing. It issues them concurrently, and RFC 8305 recommends giving the AAAA query a small head start or waiting a brief interval for it, so that IPv6 addresses are available in time to be tried first — but it must not wait forever for AAAA if A has already answered. The address sorter then orders the resolved addresses: it interleaves the two families (so the attempt list alternates IPv6, IPv4, IPv6, IPv4) while keeping the overall IPv6 preference, and it applies destination-address selection rules (RFC 6724) so that, for example, a reachable global IPv6 address outranks a less-preferred one.
The attempt scheduler is the heart of the algorithm. It walks the sorted address list and starts a connection attempt to the first address (typically IPv6). It then waits the connection attempt delay — the recommended default is around 250 milliseconds — before starting an attempt to the next address (typically the first IPv4). If more addresses remain and none has connected, it continues launching staggered attempts at the delay interval. This produces a small, controlled fan-out of overlapping connection attempts rather than one-at-a-time serialization or an all-at-once flood.
The connect races are the actual TCP (or QUIC) handshakes in flight. Because attempts are staggered, on a healthy IPv6 network the IPv6 handshake usually completes within the 250ms window, before the IPv4 attempt is ever started — so only one connection is made. The first to connect wins: the moment any attempt completes its handshake, the client adopts that socket and cancels the rest, sending resets or simply abandoning the pending SYNs so it does not leave half-open connections on servers.
The connection attempt delay deserves its own box because its value is the central tuning tradeoff. Too short, and healthy IPv6 does not get enough head start, so IPv4 attempts launch needlessly and the race biases toward IPv4, wasting connections and undermining the preference. Too long, and a broken-IPv6 network makes the user wait the full delay before IPv4 even starts, hurting the failure case. The ~250ms default is chosen to comfortably exceed a typical intra-continent RTT (so healthy IPv6 usually wins) while staying well under human perception of slowness (so broken IPv6 recovers imperceptibly). Implementations sometimes adapt it based on measured RTT to the destination.
Finally, caching closes the loop across connections. After a successful connection, the client remembers which address family (and often which specific address) won for that destination, and biases the next connection to that destination accordingly — starting with the family that worked last time and, in some designs, shrinking or skipping the delay. This means the cost of the race is paid mostly on the first connection to a host; subsequent connections go straight to the known-good path. The cache must expire, though, because network conditions change — an IPv6 path that was broken this morning may be fixed this afternoon — so the algorithm periodically re-races to rediscover a recovered or newly-preferred family rather than pinning to a stale decision forever.
End-to-end flow
Walk the healthy case first. A browser opens a connection to example.com on a network with working IPv6. It issues AAAA and A queries concurrently; the AAAA answer arrives (perhaps with a slight head start), giving an IPv6 address. The scheduler starts an IPv6 TCP handshake at t=0. The path is healthy, so the SYN-ACK returns in, say, 30ms and the handshake completes well within the 250ms attempt delay. Because IPv6 connected before the IPv4 attempt was ever scheduled, no IPv4 connection is made at all. The browser uses the IPv6 socket, caches 'IPv6 won for example.com,' and hands the socket to TLS. IPv6 was preferred, IPv6 was used, and nothing was wasted.
Now the broken case. The user moves to a network whose IPv6 is misconfigured and black-holes traffic. Same destination: AAAA and A both resolve, the scheduler starts the IPv6 handshake at t=0. The SYN vanishes into the broken path — no SYN-ACK, no reset, just silence. At t=250ms, the attempt delay elapses with no IPv6 connection, so the scheduler starts the IPv4 handshake. IPv4 is healthy; its SYN-ACK returns in 30ms, the handshake completes around t=280ms, and IPv4 wins. The client cancels the still-pending IPv6 attempt and uses the IPv4 socket. Total added latency from the broken IPv6: roughly 250ms — imperceptible — versus the 20-plus seconds a strict sequential fallback would have cost.
Consider a multi-address destination. A large service returns several IPv6 and several IPv4 addresses. The sorter interleaves them preferring IPv6; the scheduler launches the first IPv6 at t=0, the first IPv4 at t=250ms, the second IPv6 at t=500ms, and so on, each staggered by the delay, until one connects. This gracefully handles the case where a specific IPv6 address is unreachable but another is fine, or where the whole IPv6 family is down — the algorithm keeps stepping through addresses at a controlled pace, always with the first success winning and the rest cancelled, never flooding all addresses simultaneously.
Now the resolution-stall case, which is easy to overlook. On some paths the AAAA query is slow or lost while the A query answers promptly. A naive client that waits for both records before connecting would stall on the missing AAAA. Happy Eyeballs instead proceeds: having an A record in hand and no AAAA yet after a short wait, it begins the IPv4 attempt rather than blocking, and if the AAAA arrives later it can still incorporate the IPv6 address into the race. The user is not held hostage to a slow DNS answer for a family that may not even be reachable, which is a distinct win from the connection-race behavior and just as important to perceived speed.
Finally the cached-path case. The next time the user connects to example.com from the healthy-IPv6 network, the client consults its cache, sees IPv6 won last time, and biases the race — starting with IPv6 and, in some implementations, skipping or shortening the delay because it is confident. The connection is established with minimal overhead. Periodically, though, the client re-races the full algorithm even for cached destinations, so that if the network's IPv6 has since broken (or a previously-broken IPv6 has been fixed), the cache self-corrects rather than pinning the client to a path that is no longer the best one.