What Happens When You Type a URL and Press Enter
DNS lookups, TCP and TLS handshakes, HTTP requests, servers, and the browser's rendering pipeline. The whole journey of a web page, explained so it finally clicks.
Read →Same code. Same .env. Works on my Linux box, dies on the Windows server with ECONNREFUSED ::1:3306. Here's what localhost actually does under the hood, why 127.0.0.1 is not always the same thing, and the rules I now follow in every .env file.
Two hours. That's how long I lost to a one-word difference in an .env file.
The app ran perfectly on my Ubuntu dev machine. The exact same commit, deployed to a client's Windows Server box, threw this on boot:
Error: connect ECONNREFUSED ::1:3306
at TCPConnectWrap.afterConnect [as oncomplete]
MySQL was running. The port was open. Credentials were right. The firewall was off. The only difference between working and broken was this:
DB_HOST=localhost # broken on Windows
DB_HOST=127.0.0.1 # works everywhere
If you've ever stared at ::1 and wondered where those colons came from, this post is for you. Most of us treat localhost and 127.0.0.1 as synonyms. They aren't — and the gap between them shows up differently on Linux and Windows, in Docker, and even between MySQL and PostgreSQL.
127.0.0.1 is a literal IPv4 address. Nothing resolves it. It means one thing on every operating system.localhost is a hostname. It gets resolved, and modern systems resolve it to both 127.0.0.1 and ::1 (the IPv6 loopback) — often with IPv6 first.::1 first, you get a refused connection or a stall.localhost doesn't even use TCP — it silently switches to a Unix socket, which changes your auth, your port, and your performance profile.127.0.0.1 in .env when you want predictable TCP. Use an explicit socket path when you want socket speed. Never rely on localhost to mean the same thing twice.localhostlocalhost is a reserved hostname, formalised in RFC 6761. Your application hands that string to the OS resolver, and the resolver hands back a list of addresses. On a typical modern machine that list is:
::1
127.0.0.1
Note the order. RFC 6724 address-selection rules prefer IPv6 over IPv4 when both are available, so ::1 usually comes first.
127.0.0.1, by contrast, skips this entire step. There is no lookup, no list, no ordering policy. The client opens a socket to that address and that's it.
So the real question isn't "which is faster to resolve." It's "which address does your client actually end up talking to, and is anything listening there?"
Here's the chain of events that produced my ECONNREFUSED ::1:3306:
bind-address = 0.0.0.0. That binds IPv4 only. Nothing was listening on ::1.localhost and got ::1 first.::1:3306, got refused, and surfaced the error.
The IPv4 address was right there in the list — but the driver never got to it.This one bites a lot of people. Before Node 17, dns.lookup() defaulted to ipv4first, which reordered results to put IPv4 at the top. Node 17 changed the default to verbatim — meaning Node now returns addresses in whatever order the OS gave them. Overnight, apps that had always landed on 127.0.0.1 started landing on ::1.
If you're stuck on a codebase you can't fully change, you can force the old behaviour:
// at the very top of your entry file, before any connections
const dns = require('node:dns');
dns.setDefaultResultOrder('ipv4first');
Or at the process level:
NODE_OPTIONS=--dns-result-order=ipv4first node server.js
Node 20 also turned on autoSelectFamily by default, which races both address families and falls back quickly instead of hard-failing. That hides the symptom on newer runtimes — which is arguably worse, because the bug is still there waiting for the one environment where the fallback stalls instead of failing fast.
Same root cause, different error message:
FATAL: no pg_hba.conf entry for host "::1", user "app", database "app", SSL off
That's pg_hba.conf containing a rule for host all all 127.0.0.1/32 md5 but no matching ::1/128 line. Your app resolved localhost to IPv6, reached Postgres fine, and got rejected at the authentication layer. Add both:
host all all 127.0.0.1/32 scram-sha-256
host all all ::1/128 scram-sha-256
| Behaviour | Linux | Windows |
|---|---|---|
localhost in hosts file |
Usually an explicit 127.0.0.1 localhost line, often plus ::1 localhost |
Entries are commented out by default; the DNS Client service resolves it internally |
| Typical first address returned | ::1 on dual-stack systems, but distro-dependent |
::1 almost always, per RFC 6724 preference |
| Unix domain sockets for DBs | Supported and often the default | Not used by MySQL/Postgres builds; named pipes or shared memory instead |
| Refused connection behaviour | Instant TCP RST — fails fast | Instant refusal too, but stalls behind some security software |
Effect of bind-address = 0.0.0.0 |
IPv4 only | IPv4 only |
The practical takeaway: on Linux you often get away with localhost because your distro's /etc/hosts and your DB defaults happen to line up. On Windows that safety net isn't there.
One more Linux-specific detail worth knowing: a firewall rule that DROPs packets instead of REJECTing them turns a fast failure into a connection-timeout hang. If your app takes 20 seconds to fail instead of 20 milliseconds, check for -j DROP before you blame DNS.
localhost isn't TCP in MySQLThis is the part I wish someone had told me three years ago.
On Unix-like systems, the MySQL client library treats localhost as a special keyword, not a hostname. It doesn't resolve it at all — it connects over a Unix domain socket, typically /var/run/mysqld/mysqld.sock.
That has three consequences that will absolutely waste your afternoon:
1. Your DB_PORT is ignored. Moved MySQL to port 3307 and nothing changed? That's why. Sockets don't have ports.
DB_HOST=localhost
DB_PORT=3307 # completely ignored on Linux
2. Your GRANTs are different. MySQL treats 'app'@'localhost' and 'app'@'127.0.0.1' as two separate user accounts:
-- these are NOT the same user
CREATE USER 'app'@'localhost' IDENTIFIED BY 'secret';
CREATE USER 'app'@'127.0.0.1' IDENTIFIED BY 'secret';
Switch your .env from localhost to 127.0.0.1 to dodge the IPv6 problem, and you may suddenly get Access denied for user 'app'@'127.0.0.1'. Grant both, or grant neither and be deliberate about it.
3. Windows doesn't do this at all. There's no Unix socket, so localhost on Windows goes over TCP — which drops you right back into the IPv6 resolution problem. Same string, completely different transport, depending on the OS.
To force TCP explicitly on the CLI:
mysql -h localhost --protocol=TCP -u app -p
Don't assume the MySQL rule generalises. In libpq, host=localhost is a real hostname and goes over TCP. To use a Unix socket you pass a directory path:
# TCP
DATABASE_URL=postgres://app:secret@localhost:5432/app
# Unix socket — note the path as the host
PGHOST=/var/run/postgresql
Two databases, two opposite interpretations of the same word. This is exactly why "just use localhost" is bad advice.
This is the question that got me digging in the first place, and the honest answer has two layers.
Name resolution is not your bottleneck. localhost resolves from the hosts file or an internal resolver in microseconds. If you're seeing multi-second delays, you're looking at a failed IPv6 connection attempt and retry, not a slow lookup.
Unix sockets genuinely are faster than TCP loopback. A Unix domain socket skips the TCP/IP stack entirely — no packet headers, no checksums, no connection state machine. For chatty workloads with lots of small queries, that overhead is real and measurable.
But the size of the gain depends on your query mix, your driver, and your hardware, so measure it on your own box instead of trusting a number from a blog post:
# TCP loopback
mysqlslap --host=127.0.0.1 --port=3306 --user=app --password \
--concurrency=50 --iterations=10 --auto-generate-sql
# Unix socket
mysqlslap --socket=/var/run/mysqld/mysqld.sock --user=app --password \
--concurrency=50 --iterations=10 --auto-generate-sql
For a quick latency comparison from your application layer:
console.time('tcp');
for (let i = 0; i < 1000; i++) await pool.query('SELECT 1');
console.timeEnd('tcp');
Run it against both configs. If the difference doesn't show up in your p99, optimise something else — your N+1 queries are costing you more than your transport ever will.
Just when you've settled on 127.0.0.1, containers change the rules again.
Inside a container, 127.0.0.1 means the container itself. Not your host. Not the database running on your laptop. A Node container trying to reach a host MySQL on 127.0.0.1:3306 is talking to its own empty loopback.
What to use instead:
# talking to another compose service — use the service name
DB_HOST=mysql
# talking to the host machine, Docker Desktop (Mac/Windows)
DB_HOST=host.docker.internal
# talking to the host machine, Docker on Linux — requires the flag below
DB_HOST=host.docker.internal
# docker-compose.yml — needed on Linux, where host.docker.internal isn't automatic
services:
api:
extra_hosts:
- "host.docker.internal:host-gateway"
WSL2 is its own adventure. Windows can reach services in WSL2 via localhost thanks to localhost forwarding, but going the other way — from WSL2 to a Windows-hosted service — historically needed the host's IP, not localhost. Newer Windows builds offer mirrored networking mode that makes this symmetric. If you have one dev on WSL2 and one on native Linux, expect their .env files to differ.
After the Windows Server incident, these went straight into our onboarding docs:
127.0.0.1 in .env, not localhost. It's unambiguous, it's the same on every OS, and it removes an entire class of IPv6 bugs.PGHOST=/var/run/postgresql or MYSQL_SOCKET=/var/run/mysqld/mysqld.sock. Don't get it by accident through a keyword.bind-address = 0.0.0.0 is IPv4 only. Use :: if you want both families. Know which one you picked.'app'@'localhost' and 'app'@'127.0.0.1' are different accounts in MySQL.pg_hba.conf symmetric. Every 127.0.0.1/32 line needs a ::1/128 twin.DB_HOST an environment variable so compose, Kubernetes, and local dev can each supply the right value.When a local connection fails, run these in order:
# 1. What does localhost actually resolve to here?
getent hosts localhost # Linux
ping -4 localhost && ping -6 localhost # Windows
# 2. What is actually listening, and on which family?
ss -tlnp | grep 3306 # Linux
netstat -ano | findstr :3306 # Windows
# 3. Does the IPv4 address work when you force it?
nc -vz 127.0.0.1 3306 # Linux
Test-NetConnection 127.0.0.1 -Port 3306 # Windows
# 4. Does IPv6 work?
nc -vz ::1 3306
If step 3 succeeds and step 4 fails, you've found it. Change .env to 127.0.0.1 and move on with your day.
localhost looks like a constant and behaves like a variable. It resolves differently across operating systems, means "Unix socket" to MySQL and "TCP hostname" to PostgreSQL, points at the wrong machine inside a container, and quietly changed behaviour in Node 17. 127.0.0.1 does exactly one thing, everywhere, forever.
That predictability is worth more than any micro-optimisation. Be explicit in your config, bind your services deliberately, and test on the OS you're actually shipping to.
Have you been bitten by the ::1 problem, or found a stranger platform difference? Drop it in the comments — I'd rather read about your two lost hours than spend another two of my own.
DNS lookups, TCP and TLS handshakes, HTTP requests, servers, and the browser's rendering pipeline. The whole journey of a web page, explained so it finally clicks.
Read →There are dozens of HTTP status codes, but about fifteen cover nearly everything. What each one means, when to return it, and the mix-ups (401 vs 403, 301 vs 302) that confuse everyone.
Read →A, AAAA, CNAME, MX, TXT and NS records in plain English, why DNS changes seem slow, and the troubleshooting commands that tell you what is really going on.
Read →