Summary
fetch() does not implement Happy Eyeballs (RFC 8305 / RFC 6555). When a hostname resolves to both IPv4 and IPv6 addresses and the IPv6 path is blackholed (TCP SYN accepted but no data returned), fetch() connects to the IPv6 address and waits for the full timeout — it never falls back to IPv4.
Deno.connect and Deno.connectTls were fixed in #25661 / #31726 (Deno 2.9), but fetch() still uses a code path that does not race connections across address families.
Reproduction
Self-contained script — starts an IPv4 HTTP server and an IPv6 TCP blackhole on the same port, then fetches http://localhost:<port>/:
const PORT = 19371;
// IPv4: real HTTP server that responds immediately
const ipv4Server = Deno.serve({
hostname: "127.0.0.1",
port: PORT,
onListen: () => console.log(`[ipv4] listening on 127.0.0.1:${PORT}`),
}, () => new Response("ok from ipv4"));
// IPv6: accepts TCP but never sends data (simulates blackhole)
const ipv6Listener = Deno.listen({ hostname: "::1", port: PORT, transport: "tcp" });
console.log(`[ipv6] blackhole on [::1]:${PORT}`);
const conns: Deno.Conn[] = [];
const acceptLoop = (async () => {
try {
for await (const conn of ipv6Listener) {
console.log("[ipv6] accepted connection (blackholing)");
conns.push(conn);
}
} catch { /* closed */ }
})();
await new Promise((r) => setTimeout(r, 200));
// Test: fetch localhost (resolves to both ::1 and 127.0.0.1)
console.log("\nfetch('http://localhost:PORT/') with 5s timeout...");
const start = performance.now();
try {
const resp = await fetch(`http://localhost:${PORT}/`, {
signal: AbortSignal.timeout(5_000),
});
const body = await resp.text();
console.log(`"${body}" in ${(performance.now() - start).toFixed(0)}ms`);
} catch (e) {
console.log(`failed in ${(performance.now() - start).toFixed(0)}ms: ${(e as Error).message}`);
}
// Control: fetch directly to IPv4
console.log("\nfetch('http://127.0.0.1:PORT/') (control)...");
const start2 = performance.now();
const resp2 = await fetch(`http://127.0.0.1:${PORT}/`);
console.log(`"${await resp2.text()}" in ${(performance.now() - start2).toFixed(0)}ms`);
// Cleanup
for (const c of conns) try { c.close(); } catch { /* */ }
ipv6Listener.close();
await ipv4Server.shutdown();
$ deno run --allow-net repro.ts
[ipv4] listening on 127.0.0.1:19371
[ipv6] blackhole on [::1]:19371
fetch('http://localhost:PORT/') with 5s timeout...
[ipv6] accepted connection (blackholing)
failed in 5002ms: The operation was aborted due to timeout
fetch('http://127.0.0.1:PORT/') (control)...
"ok from ipv4" in 6ms
Expected
fetch() should race connections across address families per RFC 8305 — after ~250–300ms with no response from [::1], start a parallel connection to 127.0.0.1 and use whichever connects first. The request should succeed in ~300ms, not hang for the full timeout.
Actual
fetch() connects to [::1] (IPv6), waits for the full 5-second timeout, and fails. 127.0.0.1 is never attempted.
Context
Versions tested
- Deno 2.8.3: broken
- Deno 2.9.4: broken
Summary
fetch()does not implement Happy Eyeballs (RFC 8305 / RFC 6555). When a hostname resolves to both IPv4 and IPv6 addresses and the IPv6 path is blackholed (TCP SYN accepted but no data returned),fetch()connects to the IPv6 address and waits for the full timeout — it never falls back to IPv4.Deno.connectandDeno.connectTlswere fixed in #25661 / #31726 (Deno 2.9), butfetch()still uses a code path that does not race connections across address families.Reproduction
Self-contained script — starts an IPv4 HTTP server and an IPv6 TCP blackhole on the same port, then fetches
http://localhost:<port>/:Expected
fetch()should race connections across address families per RFC 8305 — after ~250–300ms with no response from[::1], start a parallel connection to127.0.0.1and use whichever connects first. The request should succeed in ~300ms, not hang for the full timeout.Actual
fetch()connects to[::1](IPv6), waits for the full 5-second timeout, and fails.127.0.0.1is never attempted.Context
Deno.connectandDeno.connectTlshave Happy Eyeballs since feat(ext/net): implement Happy Eyeballs forDeno.connectandDeno.connectTls#31726 (Deno 2.9) withautoSelectFamily— this works correctly.fetch()uses a separate code path through hyper that does not benefit from theDeno.connectfix.HttpConnectorhas built-in Happy Eyeballs with a 300ms default timeout (docs), but Deno's fetch integration does not appear to use it. @seanmonstar suggested this approach in Implement Happy Eyeballs for TCP connections #25661.fetch()call to a dual-stack hostname where one address family is blackholed — a common scenario on misconfigured networks and partial IPv6 deployments.Versions tested