localhost is not 127.0.0.1
Two Node default-policy changes drive every behavior in this article.
- v17 (2021):
dns.lookupflippedverbatimfromfalsetotrue.localhostnow resolves to::1(IPv6) first on macOS. Permanent. v17 / v18 / v20 / v22 / v24 all do this. - v20 (2023):
net.connectflippedautoSelectFamilyfromfalsetotrue. Client connections to dual-family hostnames try IPv4 and IPv6 in parallel, falling back automatically (happy eyeballs, RFC 8305). Permanent.http.get,https.get,fetchall inherit this.
Three years apart. Most articles read them as one event. They are two separate policy shifts: First v17 changed the way localhost resolves, then v20 added a connect-side fallback so clients still worked despite the v17 default change.
I learned this the hard way. My backend listened on 127.0.0.1:1234. My frontend called localhost:1234. It just didn't connect, and the server logs were empty, so I had nothing to go on.
I figured 127.0.0.1 and localhost were interchangeable, which turns out to be wrong.
The bug
Node 17 changed dns.lookup defaults. Before v17 it post-sorted results to prefer IPv4. From v17 it returns whatever getaddrinfo gives back, no re-sorting. On macOS, getaddrinfo returns ::1 first for localhost when both A and AAAA records exist, no matter what order they sit in /etc/hosts.
# /etc/hosts (default macOS)
127.0.0.1 localhost
::1 localhost
Swap those two lines and behavior does not change. The OS resolver picks IPv6, not the file order.
My backend bound to 127.0.0.1, IPv4 only. My frontend resolved localhost to ::1, IPv6. The connection went to an address nobody was listening on. No error on the server because the request never arrived.
Frontend: fetch("http://localhost:1234")
→ DNS resolves localhost to ::1
→ connects to [::1]:1234
→ nothing listening there → connection refused
Backend: listening on 127.0.0.1:1234
→ never sees the request
→ no error logged
The fix: bind to '::' (accepts both families on macOS and Linux) or to '0.0.0.0' (IPv4 only). Or use 127.0.0.1 in the frontend URL instead of localhost.
Three addresses, three meanings
They seem like the same thing at a glance, though they actually work in different ways.
| Address | What it means | IPv6 equivalent |
|---|---|---|
127.0.0.1 | Loopback. Always points to this machine. | ::1 |
localhost | A hostname. Resolved via DNS / hosts file. | same, depends on resolution |
0.0.0.0 | Wildcard. "All interfaces" when listening, "no address" otherwise. | :: |
127.0.0.1: the loopback address
Loopback is a virtual network interface. No physical cable. No network card. Packets sent to 127.0.0.1 never leave the machine. The kernel routes them back to the same host.
IPv4 reserves the entire 127.0.0.0/8 range for loopback. 16 million addresses, all pointing to the same machine. You can run Server A on 127.0.0.4 and Server B on 127.0.5.4, both local, both loopback.
IPv6 has exactly one loopback address: ::1. No range. The designers learned from IPv4's waste.
localhost: a hostname, not an address
localhost is just a name. The browser does not shortcut it to 127.0.0.1. It goes through DNS resolution like any other hostname. First it checks /etc/hosts, then the system resolver.
You can override it:
# point localhost to a different loopback address
127.0.1.4 localhost
Browsers treat localhost as a trusted origin. Firefox shows "not secure" for http://0.0.0.0:3000 but not for http://localhost:3000, even though both are HTTP without TLS. localhost is on the browser allowlist. IP addresses are not.
0.0.0.0: it depends on who's asking
0.0.0.0 changes meaning by context.
As a listen address (server): "Accept connections on every network interface." Loopback (127.0.0.1), LAN IP (192.168.1.x), any other IPv4 interface. IPv4 only, does not accept IPv6.
As a destination (client): No spec-defined behavior. macOS and Linux kernels treat TCP to 0.0.0.0 as loopback. Browsers add quirks: Safari rejects, Chrome accepts, Firefox accepts with a "not secure" warning. ping 0.0.0.0 fails because ICMP has no fallback logic.
What I'd tell someone starting a local dev server
- Listen on
'::'when you want one socket that handles both families. macOS and Linux default to IPv6 sockets that accept IPv4 as well, making this the broadest "accept everything" option. Use'0.0.0.0'only if you specifically want IPv4 wildcard. - Connect to
127.0.0.1, notlocalhost.127.0.0.1is an IP literal, no DNS step, always IPv4.localhostgoes through DNS, result depends on Node version and OS. This is the root cause of every FAIL in the tables below. - Silent failure = IPv4/IPv6 mismatch. No error, no log. The request went to an address nobody is listening on.
Full test results (Node 17 vs 22, macOS)
Six tables. Backend, optional proxy, and browser test every combination of localhost / 127.0.0.1 / 0.0.0.0 as listen and connect targets.
1. Browser → Node 17 backend (direct) — 2 FAILs
| Server listen | Browser URL | Result |
|---|---|---|
localhost (→ ::1) | localhost | OK |
localhost (→ ::1) | 127.0.0.1 | FAIL |
localhost (→ ::1) | 0.0.0.0 | FAIL |
127.0.0.1 | localhost | OK |
127.0.0.1 | 127.0.0.1 | OK |
127.0.0.1 | 0.0.0.0 | OK |
0.0.0.0 | localhost | OK |
0.0.0.0 | 127.0.0.1 | OK |
0.0.0.0 | 0.0.0.0 | OK |
2. Browser → Node 22 backend (direct) — 2 FAILs
Same as Node 17. The localhost → ::1 bind behavior is permanent since v17.
3. Browser → Node 17 proxy → Node 22 backend — 4 FAILs
| Backend listen (Node 22) | Proxy connects via (Node 17) | Result |
|---|---|---|
localhost (→ ::1) | localhost | OK |
localhost (→ ::1) | 127.0.0.1 | FAIL |
localhost (→ ::1) | 0.0.0.0 | FAIL |
127.0.0.1 | localhost | FAIL |
127.0.0.1 | 127.0.0.1 | OK |
127.0.0.1 | 0.0.0.0 | OK |
0.0.0.0 | localhost | FAIL |
0.0.0.0 | 127.0.0.1 | OK |
0.0.0.0 | 0.0.0.0 | OK |
4. Browser → Node 22 proxy → Node 22 backend — 2 FAILs
| Backend listen (Node 22) | Proxy connects via (Node 22) | Result |
|---|---|---|
localhost (→ ::1) | localhost | OK |
localhost (→ ::1) | 127.0.0.1 | FAIL |
localhost (→ ::1) | 0.0.0.0 | FAIL |
127.0.0.1 | localhost | OK |
127.0.0.1 | 127.0.0.1 | OK |
127.0.0.1 | 0.0.0.0 | OK |
0.0.0.0 | localhost | OK |
0.0.0.0 | 127.0.0.1 | OK |
0.0.0.0 | 0.0.0.0 | OK |
5. Browser → Node 17 proxy → Node 17 backend — 4 FAILs
Same as table 3. Backend version does not matter. The extra FAILs come from Node 17 proxy resolving localhost to ::1 without fallback.
6. Browser → Node 22 proxy → Node 17 backend — 2 FAILs
Same as table 4. Node 22 proxy has happy eyeballs, so proxy via localhost to IPv4 backends works.
How dns.lookup('localhost') changed across Node versions
Node 17 changed how localhost resolves (IPv6 first). Node 20 added the fallback that lets clients still connect when ::1 fails. Two separate releases, two separate fixes, often conflated as "Node 17 fixed it" when v17 only did half the work.
| Node | dns.lookup('localhost') | http.get fallback |
|---|---|---|
| v14 (pre-17) | 127.0.0.1 (IPv4) | N/A, already IPv4 |
| v17 | ::1 (IPv6) | No |
| v18 | ::1 (IPv6) | No |
| v20 | ::1 (IPv6) | Yes (happy eyeballs, RFC 8305) |
| v22 | ::1 (IPv6) | Yes |
| v24 | ::1 (IPv6) | Yes |
Try it yourself
const http = require('http');
const dns = require('dns');
dns.lookup('localhost', (err, address, family) => {
console.log(`dns.lookup → ${address} (IPv${family})`);
});
const server = http.createServer((req, res) => {
res.writeHead(200);
res.end('ok');
});
server.listen(9999, '127.0.0.1', () => {
http.get('http://localhost:9999', () => {
console.log('http.get → OK');
server.close();
}).on('error', (err) => {
console.log('http.get → FAIL: ' + err.code);
server.close();
});
});nvm use 18 && node test.js fails. nvm use 20 && node test.js succeeds. Same DNS result both times. The difference is happy eyeballs.
Reading the FAIL patterns
Every FAIL across the six tables maps to one of two mechanisms.
Category A. Server on IPv6 only, client uses IPv4 literal.
- Trigger: backend
listen('localhost')(binds::1) and client writes127.0.0.1or0.0.0.0. - Mechanism: client uses an IP literal, no DNS step, no second candidate to fall back to. Server has no IPv4 socket.
- Where: 2 cells in every table. No Node version fixes this.
- Fix: backend writes
listen('0.0.0.0')orlisten('::').
Category B. Client writes localhost from v17/v18, no happy eyeballs.
- Trigger: backend on IPv4 (
127.0.0.1or0.0.0.0), client writeslocalhostfrom Node 17 or 18. - Mechanism: client resolves
localhostto::1, server is not there, client gives up because v17/v18 has no fallback. - Where: 2 extra cells in tables 3 and 5 (Node 17/18 proxy).
- Fix: upgrade client to Node 20+, or rewrite client to use
127.0.0.1.
What server.listen() actually binds to
listen() input | bound address | family |
|---|---|---|
'localhost' | ::1 | IPv6 |
'127.0.0.1' | 127.0.0.1 | IPv4 |
'0.0.0.0' | 0.0.0.0 | IPv4 wildcard, IPv4 only |
'::' | :: | IPv6 wildcard, also accepts IPv4 on macOS/Linux |
'::1' | ::1 | IPv6 |
Same across v17, v18, v20, v22, v24. listen('localhost') always binds IPv6 only.
Why doesn't the server fall back to IPv4 by itself?
Bind is atomic. The socket binds to one address, succeeds or fails. Once ::1 succeeds, Node has no reason to "try another." Happy eyeballs is a client-side TCP connect mechanism that tries multiple candidates in parallel. Server-side bind is passive, single-target, no analog.
If you want a server on both families, ask for it: listen('::') accepts IPv4 too on macOS and Linux. Or run two listens, one IPv4 and one IPv6.
Appendix: IPv6 link-local (fe80)
IPv6 introduced link-local addresses (fe80::/10). They work on a single network link without any router or DHCP. When a device joins a network, it assigns itself a fe80:: address automatically (SLAAC), uses it to discover the router via NDP, then gets a global IPv6 address.
Three rules for fe80 communication:
- Same physical link (same Wi-Fi access point, same switch)
- Same subnet
- Same protocol (Wi-Fi and Ethernet can't cross-talk via
fe80)
192.168.x.x is routed: packets go through the router. fe80:: is link-local: packets travel directly between devices on the same link, no router involved.
# Access a server on the same Wi-Fi via link-local (no router)
curl http://[fe80::xxxx:xxxx:xxxx:4c27%en0]:3000
# Access the same server via private IPv4 (through router)
curl http://192.168.1.42:3000