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:

One connection to a server listening on 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
connectionSYN retries allowedmedian
Python on Windows, localhostnone0.5 ms
Python on Windows, localhost1503.5 ms
Python on Windows, localhost21,015.8 ms
Python on Windows, localhost31,518.3 ms
Python on Windows, localhost4 (the default)2,017.7 ms
Python on Windows, localhost52,536.6 ms
Python on Windows, localhostuntouched socket2,018.5 ms
Node on Windows, localhost, whole HTTP requestnone (libuv sets it)1.0 ms
Python on Windows, 127.0.0.1untouched socket0.1 ms
Python on Linux, localhostnot applicableunder 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:

  1. The client runs on Windows.
  2. It connects to the name localhost, not an address.
  3. The server listens on IPv4 only. Ollama does by default. So does llama.cpp's llama-server (its default --host is 127.0.0.1), and so does uvicorn.
  4. 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.
  • localhost is 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.