localhost resolves to ::1 or is slow on Mac

One name, two loopback addresses, and servers that only listen on one of them.

On a Mac, localhost is two addresses: ::1 for IPv6 and 127.0.0.1 for IPv4, and the system resolver lists ::1 first. A dev server that listens on only one of them works at one URL and refuses at the other. Nothing is broken; the client and the server just picked different halves of the same name.

What localhost resolves to

The stock /etc/hosts has a line for each, under a comment asking you not to change them:

127.0.0.1       localhost
255.255.255.255 broadcasthost
::1             localhost

Ask the system resolver, the one browsers, curl, and most runtimes use:

dscacheutil -q host -a name localhost

On current macOS (14, 15, and 26) it prints the ipv6_address: ::1 entry before ip_address: 127.0.0.1. A client that tries only the first address it gets back tries ::1.

Find out what is listening

Replace 3000 with your port:

lsof -nP -iTCP:3000 -sTCP:LISTEN

The NAME column says which address the server bound:

Then try each address directly. -4 and -6 force the family:

curl -sv -4 http://localhost:3000/ 2>&1 | grep -E "Trying|Connected|refused"
curl -sv -6 http://localhost:3000/ 2>&1 | grep -E "Trying|Connected|refused"

Without either flag, curl tries ::1, gets "Connection refused", and moves on to 127.0.0.1 in the same millisecond. Browsers fall back the same way. That is why the page loads in the browser while a test runner, a proxy, or a script on the same machine says ECONNREFUSED ::1:3000.

What Node.js changed

Node 17 changed the default order of dns.lookup results from IPv4 first to verbatim: whatever order the operating system returns. On a Mac that means ::1 first, with two effects:

To check what your Node does:

node -p "require('dns').getDefaultResultOrder()"
node -p "require('net').getDefaultAutoSelectFamily()"

Current versions print verbatim and true. To get the old IPv4-first order for one process, run it with node --dns-result-order=ipv4first, or set it in NODE_OPTIONS.

The fixes, in order of preference

Leave the ::1 localhost line in /etc/hosts alone. Removing it hides the mismatch on this Mac and leaves the same bug waiting on every colleague's machine.

When localhost is slow rather than refused

A refused connection to ::1 costs almost nothing, so a real delay usually has another cause:

To time the lookup on its own, apart from the connection:

time dscacheutil -q host -a name myapp.local

Both lines, one switch

If your dev hostnames live in /etc/hosts, a HostBar profile holds the 127.0.0.1 and ::1 lines for each name together, so the IPv6 half never goes missing, and switches them off again after the hours you set. Its resolve panel shows the hosts-file line for a name and what macOS answers for it, without reaching for dscacheutil.

The HostBar window with the staging-local profile selected and switched on: two entries for an api.staging hostname, one on 127.0.0.1 and one on ::1, and expiry set to 8 hours.

Download HostBar