Why localhost costs two seconds on Windows
We were running a batch job against a local LLM on a Windows dev machine, one model call per item. Every single call came back two seconds late.
Not roughly two. 2.05 seconds, every call, whether the model wrote a paragraph or a single token. The model server said a one-token request took it 0.02 seconds. Our stopwatch said 2.07.
That consistency was the clue. A delay that doesn't grow with the work isn't in the work. The server's own timing was honest, so the two seconds were being spent before the request ever reached it.
The fix was one line of config:
LLM_BASE_URL=http://127.0.0.1:11434/v1
localhost became 127.0.0.1, and the gap dropped to 0.015 seconds.
Most write-ups stop there. The reason turned out to be more interesting than the fix.
The usual explanation is half of it
Search for this and you'll find the standard answer. It's right, as far as it goes.
localhost has two addresses: ::1 for IPv6 and 127.0.0.1 for IPv4.
Windows hands them out IPv6 first. (The order comes from the address
selection table, which netsh interface ipv6 show prefixpolicies will
print: ::1 has precedence 50, IPv4 addresses have 35.)
Ollama listens on IPv4 only. So the client tries ::1, nothing is
listening there, the connection is refused, and it falls back to
127.0.0.1.
Python's socket.create_connection, and the copy of it inside urllib3
that requests uses, tries addresses one at a time. It waits for the
first to fail before it starts the second.
All true. But a refused connection is supposed to be fast. When nothing is listening, the other side answers the connection request (a SYN) with a reset (an RST) straight away, and the client moves on. On Linux that takes microseconds.
So where do two seconds come from?
Windows doesn't take the first no
Here's the same connection, timed on a Windows 11 machine, against a
throwaway server listening on 127.0.0.1 only:
Python create_connection |
median of 5 |
|---|---|
to 127.0.0.1 |
0.1 ms |
to localhost |
2,021 ms |
to ::1, where nothing listens |
2,038 ms |
The last row is the whole story. The refusal alone takes two seconds. The fallback to IPv4 is instant once it starts. It just can't start until Windows has finished saying no.
What Windows does with a refused connection is try again. It treats the
reset as worth retrying, so it sends the SYN again half a second later,
up to a limit. You can see the limit in
netsh interface tcp show global: Max SYN Retransmissions : 4.
Four retries, 500 ms apart. Two seconds.
You can prove it, too. Winsock lets a program set that limit on a single
socket, with an ioctl called SIO_TCP_INITIAL_RTO:
import ctypes
from ctypes import wintypes
SIO_TCP_INITIAL_RTO = 0x98000011
NO_SYN_RETRANSMISSIONS = 0xFE # 0 means "the system default", not zero
class TCP_INITIAL_RTO_PARAMETERS(ctypes.Structure):
_fields_ = [("Rtt", ctypes.c_ushort), ("MaxSynRetransmissions", ctypes.c_ubyte)]
ws2 = ctypes.WinDLL("ws2_32")
ws2.WSAIoctl.argtypes = [ctypes.c_size_t, wintypes.DWORD, ctypes.c_void_p,
wintypes.DWORD, ctypes.c_void_p, wintypes.DWORD,
ctypes.POINTER(wintypes.DWORD), ctypes.c_void_p, ctypes.c_void_p]
def cap_syn_retries(sock, n):
p = TCP_INITIAL_RTO_PARAMETERS(0, n)
out = wintypes.DWORD()
ws2.WSAIoctl(sock.fileno(), SIO_TCP_INITIAL_RTO, ctypes.byref(p),
ctypes.sizeof(p), None, 0, ctypes.byref(out), None, None)
Set it to each value from none to five, time the localhost connect at
each, and you get a staircase. Every retry adds 500 ms, and every median
lands within 40 ms of a multiple of 500. The same ioctl also takes an
initial round-trip estimate, and setting that to 100, 250 or 1,000 ms
changed nothing, so the wait after a reset is fixed. With retries turned
off, the localhost connect takes half a millisecond.
Drag the slider to see it:
127.0.0.1 only, drawn on the time scale it actually takes. Red ticks are attempts at ::1, each refused. The dot is the moment the connection succeeds. The slider sets how many retries Windows allows on the top row's socket, through SIO_TCP_INITIAL_RTO. Every time is the median of five connects measured at that setting, with the retries drawn evenly spaced. The Node time is a whole HTTP request, so its connect alone is shorter still. Windows 11 build 26300, Python 3.12, Node 24. The Linux row is Ubuntu under WSL2 on the same machine.The measurements as a table
| connection | SYN retries allowed | median |
|---|---|---|
Python on Windows, localhost | none | 0.5 ms |
Python on Windows, localhost | 1 | 503.5 ms |
Python on Windows, localhost | 2 | 1,015.8 ms |
Python on Windows, localhost | 3 | 1,518.3 ms |
Python on Windows, localhost | 4 (the default) | 2,017.7 ms |
Python on Windows, localhost | 5 | 2,536.6 ms |
Python on Windows, localhost | untouched socket | 2,018.5 ms |
Node on Windows, localhost, whole HTTP request | none (libuv sets it) | 1.0 ms |
Python on Windows, 127.0.0.1 | untouched socket | 0.1 ms |
Python on Linux, localhost | not applicable | under 0.1 ms |
Node already fixed this
Node, on the same machine, against the same IPv4-only server, connects
through localhost in about a millisecond.
It resolves localhost the same way, ::1 first, and it gets the same
refusal. It just gets it immediately. libuv, the I/O library underneath
Node, sets exactly that ioctl: on every connect to a loopback address, it
turns SYN retransmissions off. The comment in its source says it's there
so a refused port fails "instead of waiting for 2s". It has done this
since Windows 10 version 1709.
That's why a Vite dev server proxying /api to http://localhost:8000
never notices.
Python has no such workaround. Neither does asyncio by default:
open_connection('localhost', ...) took 2,018 ms. asyncio can race the
two addresses instead of trying them in turn (the technique is called
Happy Eyeballs) if you pass happy_eyeballs_delay. With 0.25, the
connect took about 260 ms.
That caps the damage at the delay you pick. It doesn't remove it. The IPv6 attempt is still waiting out its retries. Happy Eyeballs just stops waiting for it.
Why you've never seen it in CI
Run the same script on Linux, here Ubuntu under WSL on the same machine,
and all three rows read 0.0 ms. Two separate things protect it. A
refused connection on Linux fails the moment the reset arrives. And
Ubuntu's /etc/hosts maps localhost to 127.0.0.1 only, so the IPv6
attempt never happens at all.
CI runs on Linux. Production runs on Linux. The only place this bug exists is a Windows dev machine talking to a natively installed server.
That's a great place for a bug to hide. The one person who can see it is looking at a local model, and local models are supposed to be slow.
Who pays
Four things have to line up:
- The client runs on Windows.
- It connects to the name
localhost, not an address. - The server listens on IPv4 only. Ollama does by default. So does
llama.cpp's
llama-server(its default--hostis127.0.0.1), and so does uvicorn. - The client takes Windows' retry behaviour as it comes and tries addresses one after another. That's Python's standard library and everything built on it.
Two common setups are safe without anyone meaning them to be. Ports
published by Docker Desktop listen on both stacks, so ::1 connects and
nothing is refused. Our Postgres, in a container on the same machine,
never paid the two seconds. And Tornado's listen(port) with no address
binds both 0.0.0.0 and ::, which is why the server behind this site
never noticed.
The cost is per connection, not per request. A client that keeps its connection open pays once. Ours opened a fresh one for every call, one call per item, and over a full run that came to most of a working day.
Don't just swap in 127.0.0.1 everywhere
The tempting rule is "never write localhost". It fails in the other direction.
Node's listen(port, 'localhost') binds whichever address localhost
resolves to first, and on Windows that's ::1 alone. Point Python at
127.0.0.1 for a server like that and it waits two seconds, then fails
outright: ConnectionRefusedError after 2,022 ms.
The two seconds aren't really about IPv6. They're what any refused connection to loopback costs on Windows.
So the rule is to connect to the address the server actually listens on. If you own the server, have it listen on both, and either name works.
Checking everything else
Once we'd found it, we went looking. Across 26 of our repositories, a
couple of hundred lines of code and config mention localhost. Nearly
all of them are harmless: CORS allowlists, URLs for a person to open in a
browser, Host header checks, dev-server proxies running on Node, and
deploy scripts that only ever run on Linux.
Two were the real thing. Another tool asks a local Ollama model for
answers through requests, with http://localhost:11434 as its default.
And an example config file, a few lines below the warning we had just
written about all this, was still suggesting localhost for a different
model server.
If you work on Windows and call local services from Python, the same check takes a few minutes. Start with:
git grep -n "localhost:"
For every hit that is a Python client, find out what the server on the other end listens on.
The short version
- When a delay is the same size on every call, stop profiling the work. Subtract the server's own timing from the wall clock. If a constant is left over, it's in the connection.
- On Windows, a refused connection to loopback takes two seconds, not zero. Anything that tries one address and then another inherits that.
localhostis two addresses and a preference order. Connect to the one the server listens on.- If you own the server, listen on both stacks.
localhost and 127.0.0.1 are both nine characters. On Windows, one of
them can cost you two seconds a call.