localhost is not 127.0.0.1
The backend listens on 127.0.0.1:1234, a Node.js 17 program on the frontend side connects to localhost:1234, and the connection fails. The backend log has nothing in it.
Node.js 17 resolves localhost to ::1, the IPv6 loopback address, and sends the request to [::1]:1234. Nothing listens there, so the operating system refuses the connection before it reaches the backend, and the backend has nothing to log.
Frontend (Node.js 17): 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
Why localhost resolves to ::1 first
Before Node.js 17, dns.lookup re-sorted the operating system's answer and put IPv4 first, so localhost always came back as 127.0.0.1 first.
Node.js 17 (October 2021) changed the default of the verbatim option of dns.lookup to true. It no longer re-sorts; it returns the order the operating system gives. macOS gives ::1 first.
The macOS resolver picks that order itself; which line comes first in /etc/hosts makes no difference. The default /etc/hosts has 127.0.0.1 first:
# /etc/hosts (default macOS)
127.0.0.1 localhost
::1 localhost
With the two lines swapped, the lookup still returns ::1 first:
/etc/hosts order dns.lookup('localhost', { all: true })
127.0.0.1 first [{ address: '::1', family: 6 }, { address: '127.0.0.1', family: 4 }]
::1 first [{ address: '::1', family: 6 }, { address: '127.0.0.1', family: 4 }]
Why it connects again from Node.js 20
Node.js 20 (April 2023) changed the default of the autoSelectFamily option of net.connect to true. With it on, when the client fails to connect to the first address, it tries the next one, waiting at most 250ms per address. Node.js calls this happy eyeballs, after RFC 8305. http.get and fetch use this default.
So connecting to localhost:1234, Node.js 20 tries ::1, gets refused, then tries 127.0.0.1 and connects.
| Node.js version | localhost resolves first to | After ::1 refuses, tries 127.0.0.1? | Connecting to a server that listens only on 127.0.0.1 |
|---|---|---|---|
| Node.js 16 and earlier | 127.0.0.1 | No, but not needed | Succeeds |
| Node.js 17 to 19 | ::1 | No | Fails |
| Node.js 20 and later | ::1 | Yes | Succeeds |
The table puts Node.js 18 under "No", which differs from the Node.js net docs. The docs say autoSelectFamily is on by default from 18.18.0, which would let 18 connect.
On Node.js 18.20.8, though, net.getDefaultAutoSelectFamily() returns false and the test script at the end fails to connect, so the table follows the measurement. The versions actually run were 17.9.1, 18.20.8, 20.12.2, 22.22.3 and 24.8.0, all on macOS 15.6. 16 and 19 were not run. The 16 row follows the dns.lookup docs; 19 is under "No" because the net docs say the default turned on in 20.0.0.
The server side does not try another address
When a server calls listen('localhost'), Node.js resolves localhost to ::1 and binds only that one address, not 127.0.0.1 as well. From Node.js 17 to 24, a server written this way listens on IPv6 only.
A client that connects to localhost still works, because it also resolves to ::1 first. A client that connects to 127.0.0.1 fails, because the server listens only on ::1 and nothing listens on 127.0.0.1. Node.js 20 does not help here. It moves to the next address because localhost resolves to two, ::1 and 127.0.0.1; a client that writes 127.0.0.1 directly has only that one address and nothing else to try. A server that calls listen('127.0.0.1') or listen('0.0.0.0') listens on IPv4, and a client on 127.0.0.1 connects fine.
listen() argument | Address actually bound | Connections accepted |
|---|---|---|
'localhost' | ::1 | IPv6 only |
'127.0.0.1' | 127.0.0.1 | IPv4 only |
'0.0.0.0' | 0.0.0.0 | IPv4 on every network interface |
'::' | :: | IPv6 on every network interface, and IPv4 too on macOS and Linux |
'::1' | ::1 | IPv6 only |
This table was checked with net.createServer().listen(0, host) on Node.js 17.9.1, 18.20.8, 20.12.2, 22.22.3 and 24.8.0; all five versions gave the same result.
What 127.0.0.1, localhost and 0.0.0.0 are
| Address | Meaning | IPv6 equivalent |
|---|---|---|
127.0.0.1 | Loopback address, always this machine | ::1 |
localhost | A hostname, resolved through DNS or /etc/hosts | Depends on resolution, may be ::1 |
0.0.0.0 | "Every network interface" when a server listens on it, "no address" otherwise | :: |
127.0.0.1: the loopback address
Loopback is a virtual network interface with no physical wire and no network card. Packets sent to 127.0.0.1 never leave the machine; the operating system hands them straight back to the same host.
IPv4 reserves all of 127.0.0.0/8 for loopback, over sixteen million addresses, all pointing to the same machine. Server A can listen on 127.0.0.4 and Server B on 127.0.5.4, both on this machine.
IPv6 has exactly one loopback address, ::1 (RFC 4291 section 2.5.3).
localhost: a name, not an address
Neither browsers nor Node.js replace localhost with 127.0.0.1 directly. They resolve it like any other hostname: first /etc/hosts, then the operating system's resolver. Change one line in /etc/hosts and localhost points somewhere else:
# point localhost to a different loopback address
127.0.1.4 localhost
Browsers treat localhost as a trusted origin. The W3C Secure Contexts spec lists localhost, 127.0.0.0/8 and ::1 as potentially trustworthy, so http://localhost:3000 and http://127.0.0.1:3000 get the APIs reserved for secure contexts without HTTPS. 0.0.0.0 is not on that list.
0.0.0.0: depends on who uses it
As a server's listen address: accept IPv4 on every network interface, including loopback (127.0.0.1) and the LAN IP (192.168.1.x). IPv4 only; a connection to ::1 is refused.
As a client's destination: RFC 1122 section 3.2.1.3 allows 0.0.0.0 only as a source address while a host does not yet know its own IP, never as a destination. What happens in practice is up to the operating system: on macOS 15.6, with a server on 127.0.0.1, curl http://0.0.0.0:<port>/ returns 200, and Linux also delivers it to the local host. In 2024 Oligo Security named this "0.0.0.0 Day", because a public website could use it to reach services on the user's own machine.
Browsers each handle it differently. From Chrome 139, a public website has to get the user's permission through a prompt before it can connect to 0.0.0.0 or 127.0.0.1; a user typing 0.0.0.0 into the address bar is not affected, and every version from 126 to 156 connects. Per-version measurements are in the appendix. WebKit blocks these requests too; Firefox had not blocked them as of August 2024. On the same machine, ping 0.0.0.0 reports 100% packet loss.
Full test results (Node.js 17 and 22, macOS)
Each table tests combinations of two addresses: the address the backend listens on, and the browser URL (or the address the proxy connects to). Each side tries localhost, 127.0.0.1 and 0.0.0.0, so every table has 9 rows.
Test environment:
- OS: macOS 15.6
- Browser: Google Chrome 155.0.8059.86, headless, loading each URL directly
- Proxy: forwards with Node.js's built-in
http.request, no proxy package, nofamilyorlookupoption - Node.js: 17.9.1, 22.22.3
1. Browser → Node.js 17 backend (direct): 2 FAILs
| Backend 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.js 22 backend (direct): 2 FAILs
Same as table 1. listen('localhost') listens only on ::1 on every Node.js version.
3. Browser → Node.js 17 proxy → Node.js 22 backend: 4 FAILs
| Backend listen (Node.js 22) | Proxy connects to (Node.js 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.js 22 proxy → Node.js 22 backend: 2 FAILs
| Backend listen (Node.js 22) | Proxy connects to (Node.js 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.js 17 proxy → Node.js 17 backend: 4 FAILs
Same as table 3, so the backend's Node.js version does not change the result. The two FAILs beyond table 4 both come from the Node.js 17 proxy resolving localhost to ::1 and not trying IPv4 afterwards.
6. Browser → Node.js 22 proxy → Node.js 17 backend: 2 FAILs
Same as table 4. The Node.js 22 proxy tries IPv4 after ::1 refuses, so it reaches an IPv4-only backend through localhost too.
The two causes of FAIL
The backend listens on IPv6 only and the client uses an IPv4 address. The backend calls listen('localhost'), the client uses 127.0.0.1 or 0.0.0.0. The server listens only on ::1, and nothing listens on the IPv4 address the client uses. Every table has these 2 rows, on every Node.js version. If the backend listens on '::' or '0.0.0.0' instead, these 2 rows become OK.
The client is Node.js 17 and uses localhost. The backend listens on IPv4, the client resolves localhost to ::1 and does not try 127.0.0.1 after it is refused. In tables 3 and 5, the 2 rows where the backend listens on 127.0.0.1 or 0.0.0.0 and the proxy connects to localhost are this case, while the same 2 rows in table 4 are OK. If the proxy runs on Node.js 20 or later, or connects to 127.0.0.1 instead, those 2 rows become OK too.
Test script
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();
});
});Run on macOS 15.6, switching versions with nvm: Node.js 17.9.1 and 18.20.8 print FAIL: ECONNREFUSED; 20.12.2, 22.22.3 and 24.8.0 print OK. All five print ::1 for dns.lookup; the only difference is whether the client tries IPv4 next.
Appendix: how Chrome handles 0.0.0.0
In June 2024 Chromium announced on blink-dev that it would block requests from public websites to 0.0.0.0. The announcement estimated Chrome 133; the chromestatus entry lists Chrome 129. Local Network Access later replaced that plan with a permission prompt, launching in Chrome 142.
Test environment:
- Browser: Chrome for Testing 126 to 156. It runs only the defaults written in each version's code and does not load configuration pushed from Google's servers.
- Test page: served locally, marked as a public website with
--ip-address-space-overrides, fetching a backend listening on127.0.0.1
Under Private Network Access, before a public website reaches a private network, the browser sends an OPTIONS preflight, and the server has to answer with Access-Control-Allow-Private-Network: true for the request to go through. Every version was run twice, once with the server sending that header and once without.
| Chrome version | fetch 127.0.0.1 | fetch 0.0.0.0 | When the server does not send the header |
|---|---|---|---|
| 126 to 128 | Preflight first | No preflight, goes straight to the backend | Both requests still reach the backend |
| 129 to 137 | Preflight first | Preflight first | Both requests still reach the backend |
| 138 | No preflight, goes straight to the backend | No preflight, goes straight to the backend | Both requests still reach the backend |
| 139 and later | Permission prompt | Permission prompt | Without someone clicking Allow, nothing reaches the backend |
In 138 the preflight is already gone and the prompt is not yet in, so both requests go straight through. 138 behaves the same with and without --ip-address-space-overrides, so it cannot be confirmed that the test page was treated as a public website there. From 139, adding that flag makes the requests stop at the permission prompt, so it clearly takes effect. On 141 and later, granting the permission through the Chrome DevTools Protocol (Browser.setPermission) lets both requests through, with no preflight at all.
Typing 0.0.0.0 into the address bar reached the backend on every version from 126 to 156.
Release Chrome does load configuration pushed from Google's servers, which can turn on a feature for some users before it is on by default, so some release Chrome users may have seen the permission prompt before 139.
Appendix: IPv6 link-local (fe80)
IPv6 has link-local addresses (fe80::/10) that only work on a single network link, with no router and no DHCP. A device gives itself an fe80:: address when it joins a network (SLAAC), uses it to find the router through NDP, and then gets a global IPv6 address.
RFC 4291 section 2.5.6 says routers must not forward a packet with a link-local source or destination to another link. So two devices can talk over fe80 only when they are on the same link, such as the same Wi-Fi access point or the same switch. They can connect as soon as they share the link, without waiting for a router to hand out addresses.
# 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:3000References
- Node.js dns.lookup docs (
verbatimdefault becametruein v17.0.0) - Node.js socket.connect docs (
autoSelectFamilyhistory) - Node.js DNS resolution change (issue #40537)
- http-proxy-middleware localhost resolution issue (#705)
- RFC 8305: Happy Eyeballs Version 2
- RFC 1122 section 3.2.1.3: what
0.0.0.0is for - RFC 4291: IP Version 6 Addressing Architecture (2.5.3 loopback, 2.5.6 link-local)
- W3C Secure Contexts: which origins are potentially trustworthy
- Chrome for Developers: Local Network Access
- Chromium blink-dev: Intent to Deprecate and Remove 0.0.0.0 for Private Network Access
- The Register: 0.0.0.0 Day
- Accessing IPv6 link-local from browser (StackOverflow)
- Linux virtual network interfaces (thiscute.world)