Introduction

Every time you type a web address, your device has to figure out how to reach the destination. In today's internet that question has two possible answers: an IPv4 address (the 32-bit format the world has used since 1981) or an IPv6 address (the 128-bit replacement designed to outlast the IPv4 shortage). Most servers now publish both, and most devices live on both — a setup called dual-stack.

The trouble is that "both addresses work in theory" does not mean "both addresses work right now." IPv6 paths can be broken by misconfigured tunnels, over-eager firewalls, or simply incomplete deployment. A browser that always tried IPv6 first would stall for many seconds every time it hit a broken path before falling back to IPv4 — and users would blame the website, not the transition.

Happy Eyeballs is the algorithm that solved this. Its core idea is beautifully simple: start both connection attempts at nearly the same time and use whichever one succeeds first. The winner takes the socket; the loser is quietly discarded. Users see a fast connection; the network transition happens in the background.

The name comes from the visual effect. Before the algorithm existed, broken IPv6 paths made web pages load slowly — giving users the frustrated, unblinking stare of someone watching a spinner. The fix made their eyes happy again.

Race the Connections

Adjust the simulated latency for each address family and watch the race unfold. The algorithm fires IPv6 first, waits a short stagger delay (250 ms by default in RFC 8305), then fires IPv4. Whichever handshake completes first wins the socket.

<!-- {{c_layout_comment}} -->
<div class="controls">
  <div class="control-group">
    <label for="ipv6-latency">{{label_ipv6}} (ms)</label>
    <select id="ipv6-latency">
      <option value="50">50 ms</option>
      <option value="100">100 ms</option>
      <option value="200" selected>200 ms</option>
      <option value="400">400 ms</option>
      <option value="800">800 ms</option>
      <option value="9999">{{label_timeout}}</option>
    </select>
  </div>
  <div class="control-group">
    <label for="ipv4-latency">{{label_ipv4}} (ms)</label>
    <select id="ipv4-latency">
      <option value="50">50 ms</option>
      <option value="100">100 ms</option>
      <option value="200">200 ms</option>
      <option value="400" selected>400 ms</option>
      <option value="800">800 ms</option>
      <option value="9999">{{label_timeout}}</option>
    </select>
  </div>
  <div class="control-group">
    <label for="stagger">{{label_stagger}} (ms)</label>
    <select id="stagger">
      <option value="0">0 ms</option>
      <option value="100">100 ms</option>
      <option value="250" selected>250 ms</option>
      <option value="500">500 ms</option>
    </select>
  </div>
</div>
<button id="btn-race" type="button">{{btn_race}}</button>
<!-- {{c_timeline_comment}} -->
<div class="timeline-wrap">
  <div class="timeline-label">IPv6</div>
  <div class="bar-track"><div id="bar-v6" class="bar bar-v6"></div></div>
  <div class="timeline-label">IPv4</div>
  <div class="bar-track"><div id="bar-v4" class="bar bar-v4"></div></div>
</div>
<div id="status" class="status"></div>
<div id="detail" class="detail"></div>
/* {{c_base_styles}} */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; padding: .5rem; }
.controls { display: flex; flex-wrap: wrap; gap: .7rem; margin-bottom: .8rem; }
.control-group { display: flex; flex-direction: column; gap: .2rem; }
label { font-size: .8rem; font-weight: 600; color: #555; }
select { font-size: .9rem; padding: .3rem .5rem; border: 1px solid #ccc; border-radius: 6px;
         background: #fff; cursor: pointer; }
button { font: 600 14px system-ui, sans-serif; padding: .5rem 1.1rem;
         background: #1d3557; color: #fff; border: none; border-radius: 8px; cursor: pointer; }
button:hover { background: #16304e; }
/* {{c_timeline_styles}} */
.timeline-wrap { margin: 1rem 0 .5rem; display: grid;
                 grid-template-columns: 3.5rem 1fr; gap: .35rem .5rem; align-items: center; }
.timeline-label { font-size: .85rem; font-weight: 700; color: #444; text-align: right; }
.bar-track { background: #eaeef2; border-radius: 6px; height: 22px; overflow: hidden; position: relative; }
.bar { height: 100%; width: 0; border-radius: 6px; transition: width .05s linear; }
.bar-v6 { background: #2196f3; }
.bar-v4 { background: #ff9800; }
.bar.winner { outline: 3px solid #0a7d33; }
.bar.loser { opacity: .45; }
.bar.timed-out { background: #ccc; }
/* {{c_status_styles}} */
.status { font-size: 1rem; font-weight: 700; margin: .4rem 0; min-height: 1.4em; }
.status.win-v6 { color: #1565c0; }
.status.win-v4 { color: #e65100; }
.status.timeout { color: #b71c1c; }
.detail { font-size: .82rem; color: #666; min-height: 1.2em; }
// Code not found

Notice what happens at the extremes. When IPv6 is much faster, it wins every time. When IPv6 is broken (set its latency to "timeout"), IPv4 takes over after a brief wait — still far faster than the old sequential fallback that could take 20+ seconds. The stagger delay is the key tuning knob: too short and you waste IPv4 capacity on races IPv6 would have won; too long and a broken IPv6 delays the user.

The Real Complexity

The algorithm looks trivial — "try both, keep the winner." But the engineering tradeoffs are subtle.

  • Sequential fallback (the old way): try IPv6 first; if it times out (often after 20–75 seconds), fall back to IPv4. Worst-case latency is catastrophic and users notice.
  • Parallel with no stagger: fire both at exactly the same moment. Works, but it doubles connection attempts on every request — needlessly consuming server resources when IPv6 would have won anyway.
  • Happy Eyeballs v1 (RFC 6555, 2012): stagger IPv4 by ~150–250 ms after IPv6. Cuts worst-case latency to the stagger delay. Adopted by every major browser within two years.
  • Happy Eyeballs v2 (RFC 8305, 2017): extended to sort candidate addresses by family and type, handle multiple addresses per family, manage connection pools, and give implementers a full state machine. The Δt=250 ms\Delta t = 250\text{ ms} stagger is now the recommended default.

The key insight is an asymmetry in costs. A wasted parallel attempt costs a few bytes and a server-side half-open connection — cheap. A stalled user costs a bounce. Happy Eyeballs is the algorithm that correctly prices those two outcomes.

The problem also connects to broader ideas in distributed systems. Racing two options and using the first to respond is the same pattern behind load balancing hedged requests, speculative execution in CPUs, and RAID-1 reads that query both mirrors in parallel.

Where It Matters

Happy Eyeballs is not just an IPv6 trick. The "race and use the winner" pattern appears wherever you have multiple equivalent paths and uncertain latency:

  • Browsers: Chrome, Firefox, Safari, and Edge all implement RFC 8305 or a close variant. It is why loading a dual-stack page feels instant even as IPv6 coverage is still incomplete.
  • Mobile operating systems: iOS and Android use Happy Eyeballs for all TCP and QUIC connections, not just HTTP. The OS itself picks the winning address family before the app even sees a socket.
  • DNS over HTTPS resolvers: when a resolver has multiple upstream servers, it races queries and uses the first valid response — the same pattern applied one layer down.
  • CDN and load balancer health checks: instead of a sequential probe list, some systems fire probes in parallel and route traffic to whichever endpoint responds first.
  • QUIC connection migration: QUIC can race paths simultaneously during a migration event, keeping the connection alive across network changes — a direct descendant of the eyeballs idea.

Understanding Happy Eyeballs means understanding a general engineering principle: when you cannot predict which path will be fastest, invest a small amount of wasted effort to eliminate the catastrophic tail. The same logic drives speculative execution and load balancing.

Conclusion

Happy Eyeballs is a masterclass in pragmatic algorithm design. The internet was not going to flip from IPv4 to IPv6 in a single day, and waiting for a perfect network meant users would suffer through broken paths for years. The algorithm accepted the messy reality — two address families, uncertain path quality — and turned it into a resource: race both, pay a tiny overhead, eliminate the catastrophic worst case.

The stagger delay of 250 ms is almost a rounding error in human perception. The old sequential timeout of 20+ seconds was not. That asymmetry is the entire argument for the algorithm.

The next time a page loads instantly despite a broken IPv6 path, you have Happy Eyeballs to thank — and a reminder that the best engineering solutions often look obvious in hindsight, but required someone to ask "why are we waiting at all?"

Share this article

Pick a channel — or use your device's native share sheet.

Comments

Loading comments...

https://www.kipuhub.com/en/article/happy-eyeballs/Content licensed under CC BY-NC 4.0.