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): http.get("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 |
'::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. 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. In the Chromium source, the Local Network Access check is on by default from 142: 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. Release Chrome also loads configuration pushed from Google's servers, which can turn the check on earlier or later for some users, so the version where a user first meets the prompt may not be 142. A user typing 0.0.0.0 into the address bar is not affected: every version from 126 to 156 reaches the server listening on 127.0.0.1. 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 uses127.0.0.1or0.0.0.0. - The server listens only on
::1, so 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 only on IPv4, but the client resolves
localhostto::1. After::1refuses the connection, Node.js 17 does not try127.0.0.1, so the request fails. - In tables 3 and 5, the 2 rows where the backend listens on
127.0.0.1or0.0.0.0and the proxy connects tolocalhostare this case. In table 4 the proxy runs Node.js 22, which tries127.0.0.1after the refusal, so the same 2 rows are OK. - Those 2 rows in tables 3 and 5 become OK too if the proxy runs on Node.js 20 or later, or connects to
127.0.0.1instead.
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.
With the code defaults in Chromium, a public website can reach 0.0.0.0 and 127.0.0.1 on every version from 126 to 141. From 142 the user has to click Allow first.
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. The middle two columns of the table are with the server sending that header; the last column is without it.
| Chrome version | fetch 127.0.0.1 (header sent) | fetch 0.0.0.0 (header sent) | 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 139 | Preflight first | Preflight first | Both requests still reach the backend |
| 140, 141 | No preflight, goes straight to the backend | No preflight, goes straight to the backend | Both requests still reach the backend |
| 142 and later | Permission prompt | Permission prompt | Without someone clicking Allow, nothing reaches the backend |
- 126 to 128:
127.0.0.1gets a preflight but0.0.0.0does not, because the browser treats0.0.0.0as a public address. On macOS a request to0.0.0.0reaches the services on the local machine, so a public website can reach the user's machine through0.0.0.0. That is the 0.0.0.0 Day mentioned above. - 129 to 139:
0.0.0.0gets a preflight too, treated as a local address like127.0.0.1. But when the server leaves out the header, the browser does not stop the request, and the GET still goes out. - 140, 141: the preflight is gone, to be replaced by Local Network Access, which is not on by default yet. Both requests reach the backend with no check at all.
- 142 and later: Local Network Access is on by default, and a public website gets a permission prompt before it can reach the local machine. In the Chromium source,
LocalNetworkAccessChecksis disabled by default up to 141 and enabled by default from 142.
A user typing 0.0.0.0 into the address bar reached the backend on every version from 126 to 156. These mechanisms only check requests a web page makes.
Release Chrome loads configuration pushed from Google's servers, which can turn a feature on earlier or later for some users. So the version where a release Chrome user first sees the permission prompt may differ from the table. That part was not measured.
Test method
- Browser: Chrome for Testing 126 to 156, every one of the 31 majors, on macOS 15.6.
- Test page: served locally, marked as a public website with
--ip-address-space-overrides, fetching a backend listening on127.0.0.1.
Chrome for Testing does not load configuration pushed from Google's servers, but it does load a testing config that ships in the Chromium source (testing/variations/fieldtrial_testing_config.json). That config turns some features on before they are on by default, so every version ran in two passes. The "normal start" pass loads the testing config. The "testing config off" pass adds --disable-field-trial-config and leaves only the code defaults. The table above is the testing config off pass. In the normal start pass, 138 already sends no preflight and 139 already shows the permission prompt, both earlier than the code defaults.
With the testing config off, 140 and 141 stop nothing, so the result is the same whether or not the test page is marked public, and that pass alone cannot confirm --ip-address-space-overrides took effect. In the normal start pass, the same builds stop the requests at the permission prompt, which shows the flag does take effect.
In headless mode nobody can answer the prompt. In the normal start pass, requests on 139 to 142 hang with no answer. In the testing config off pass, only 142 does. From 143, in both passes, headless Chrome treats the prompt as denied, and the console prints Permission was denied for this request to access the loopback address space. Because the normal start pass stops requests from 139, 139 to 156 also ran once more with the permission granted first through the Chrome DevTools Protocol (Browser.setPermission). With the grant, both requests reach the backend, with no preflight.
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
- Chromium 142 source:
kLocalNetworkAccessChecksenabled by default (disabled by default in 141.0.7390.122) - Chromium testing config fieldtrial_testing_config.json (139)
- The Register: 0.0.0.0 Day
- Accessing IPv6 link-local from browser (StackOverflow)
- Linux virtual network interfaces (thiscute.world)