Short answer: if a Node.js request dies with TypeError: fetch failed, AggregateError [ETIMEDOUT], or connect ENETUNREACH 2606:... but curl to the same host succeeds, the hostname probably resolves to both IPv6 and IPv4 addresses, and your machine or container has an IPv6 address family it cannot actually route. Confirm it with curl -4 versus curl -6, then either fix the network or tell Node to prefer IPv4 and fall back faster.
What does the error actually look like?
The surface message from the built-in fetch is unhelpful:
TypeError: fetch failed
at node:internal/deps/undici/undici:...
[cause]: AggregateError [ETIMEDOUT]:
at internalConnectMultiple (node:net:...)
[errors]: [
Error: connect ETIMEDOUT 2606:4700:...:443
Error: connect ENETUNREACH 2606:4700:...:443
Error: connect ETIMEDOUT 104.18.x.x:443
]
The detail is in error.cause. Log it, or you will chase the wrong problem. The giveaway is the mix of IPv6 and IPv4 addresses in one AggregateError: Node tried several addresses for the same hostname and none connected in time.
try {
await fetch("https://api.example.com/health");
} catch (err) {
console.error(err.cause?.errors ?? err.cause ?? err);
}
Takeaway: always print err.cause; fetch failed is a wrapper, and the per-address errors are the diagnosis.
Why does curl work when Node does not?
Two behaviors changed across recent Node versions, and they interact badly with half-configured networks. As of Node 17, dns.lookup returns addresses in the order the resolver gives them (verbatim), so IPv6 addresses typically come first instead of being sorted behind IPv4. As of Node 20, net.connect tries multiple addresses using "happy eyeballs" (autoSelectFamily), but each attempt has a short default timeout (documented as 250 ms), which is tight on slow or cross-region links.
curl, by contrast, has had happy-eyeballs logic for years and tends to fall back to IPv4 quickly. So the same host can feel fine from curl and flaky from Node, especially on:
- Docker containers or CI runners where the host has no IPv6 route but DNS still returns AAAA records.
- VPNs and corporate networks that advertise IPv6 but drop the packets.
- Home or office routers with a half-working IPv6 prefix.
What tripped me up: the failure is often intermittent, because it depends on which address wins the race and how loaded the link is. It looks like a flaky upstream when it is really your network.
Takeaway: AAAA records plus no working IPv6 route equals a slow or failed first attempt, and Node's default fallback window is short.
How do I confirm it is IPv6?
Force each address family from the same machine (or inside the same container) that fails:
curl -4 -sS -o /dev/null -w "ipv4: %{http_code} %{time_total}s\n" https://api.example.com/health
curl -6 -sS -o /dev/null -w "ipv6: %{http_code} %{time_total}s\n" https://api.example.com/health
getent ahosts api.example.com # which addresses, in which order
If -4 succeeds quickly and -6 hangs or prints "Network is unreachable", you have your answer. Run it inside the container, not on your laptop; the host and the container often have different routes.
You can also reproduce it in Node without any HTTP library:
import dns from "node:dns/promises";
import net from "node:net";
const host = "api.example.com";
for (const family of [4, 6]) {
try {
const { address } = await dns.lookup(host, { family });
await new Promise((resolve, reject) => {
const s = net.connect({ host: address, port: 443, timeout: 3000 }, () => { s.destroy(); resolve(); });
s.on("timeout", () => { s.destroy(); reject(new Error("timeout")); });
s.on("error", reject);
});
console.log(`IPv${family} OK (${address})`);
} catch (e) {
console.log(`IPv${family} FAILED: ${e.code ?? e.message}`);
}
}
Takeaway: test each address family separately, from inside the environment that fails.
Which fix should I use?
| Fix | Scope | Trade-off |
|---|---|---|
| Fix the network (enable IPv6 on the Docker network, or stop advertising AAAA) | Whole machine | Correct but needs infra access |
NODE_OPTIONS=--dns-result-order=ipv4first |
Whole process, no code change | Applies to all lookups, including hosts where IPv6 is the good path |
dns.setDefaultResultOrder("ipv4first") at startup |
Same, in code | Must run before the first lookup |
net.setDefaultAutoSelectFamilyAttemptTimeout(500) |
Gives slow links more time per address | Does not help if IPv6 is truly dead; only reduces false fallbacks |
| Disable IPv6 via sysctl in the container | Container | Blunt; breaks hosts that are IPv6-only |
The least invasive one I reach for first:
// top of your entrypoint, before any network call
import dns from "node:dns";
import net from "node:net";
dns.setDefaultResultOrder("ipv4first");
net.setDefaultAutoSelectFamilyAttemptTimeout(500); // ms per address; Node 20+
or, with no code change, in your Dockerfile or deployment config:
ENV NODE_OPTIONS="--dns-result-order=ipv4first"
Caveats worth knowing. These settings affect dns.lookup-based connections, which is what net, http, and fetch use by default; libraries that do their own resolution may behave differently. Pinning IPv4 first is a workaround, not a diagnosis: if your platform later becomes IPv6-only on the edge, you will want the real fix. And on Node versions before 20, autoSelectFamily is opt-in, so the attempt-timeout call does nothing useful there.
If you run on Docker and want the proper fix, either enable IPv6 on the daemon's network with a valid prefix, or accept IPv4-only and make sure your resolver stops handing back AAAA records the container can't use. As of late 2026 I still see Docker's default bridge network without IPv6 on most hosts, so this class of bug keeps showing up in containers first.
Takeaway: prefer ipv4first as a fast, reversible mitigation, and treat the network fix as the real one.
Is this a reason to pay for anything?
Not really, and that is the point. This is a configuration problem, not a tooling gap. The one case where money enters the picture is when you do not control the network: if a locked-down CI runner or serverless platform has flaky IPv6 and you cannot change it, the cheap fix is the environment variable above rather than migrating providers. I would only move platforms if the vendor cannot fix routing on their side and it blocks you repeatedly.
FAQ
Why does Node say "fetch failed" instead of the real error?
Node's built-in fetch (undici) wraps the underlying socket error. The real reason is in error.cause, and for multi-address failures in error.cause.errors.
Why does curl work but Node fetch times out?
curl falls back across IPv4 and IPv6 quickly, while Node's per-address connect attempt has a short default timeout. If IPv6 is unroutable, Node can exhaust its attempts before reaching a working IPv4 address.
How do I make Node prefer IPv4?
Set NODE_OPTIONS=--dns-result-order=ipv4first, or call dns.setDefaultResultOrder("ipv4first") before any network request.
Bottom line
If you see fetch failed with a mix of IPv6 and IPv4 addresses in the cause, test curl -4 against curl -6 from inside the failing environment before touching application code. For a quick, reversible mitigation, set --dns-result-order=ipv4first and raise the attempt timeout; for a lasting one, fix IPv6 routing or stop publishing AAAA records your network cannot use. Do not add retries around it: retries on a dead address family just make the same slow failure repeat.
Top comments (0)