Twelve hours after we changed the IPv6 settings on a Windows machine, the whole site started hanging for ten seconds before it would load. The morning change had fixed a class of page-load failures. In the evening it turned out localhost was resolving to ::1 first, which broke a three-hop reverse-proxy chain. curl with timing flags walked the chain hop by hop until the dead link fell out.
The morning fix
Earlier that day we had been chasing a different problem. Cloudflare-fronted sites like Mixcloud would stall in both browsers while the network looked healthy on paper. Pings were clean, DNS resolved fast, TCP handshakes completed. The cause was a half-disabled IPv6 stack. The per-adapter ms_tcpip6 binding was unchecked on Wi-Fi and Ethernet, but HKLM\...\Tcpip6\Parameters\DisabledComponents was never set. Windows still asked DNS for AAAA records, still preferred IPv6 in Happy Eyeballs, and still opened v6 sockets that black-holed before falling back to v4.
The fix was to set DisabledComponents = 0x20, which tells Windows to prefer IPv4. We picked 0x20 over 0xFF on purpose so Tailscale’s IPv6 kept working. After that, anything on this machine still reaching for IPv6 was reaching into a deprioritized stack that could black-hole instead of answering.
The evening symptom
Late that evening we were debugging a “session not found” error on one of our apps and noticed the whole site had gone slow. Loading https://myapp.example.com/ returned code=000 after a full ten-second timeout. The TLS handshake never completed. Hitting the machine on localhost was instant. So the app was alive, and something between the edge and the app was eating ten seconds and then giving up.
The serving stack for that site is three hops deep:
browser -> IIS (443) -> Caddy (8443) -> node app (3001)That is three places a request can die. A timed probe at each hop was faster than guessing.
Walking the chain with curl
One command carried the whole debug. It uses two flags that strip everything except what matters:
curl.exe -s -m 10 -o NUL -w "code=%{http_code} time=%{time_total}s"-o NUL throws away the body. -w prints only the HTTP status code and the total time. -m 10 caps each probe at ten seconds so a black hole can’t hang the shell forever. With that template you hit each layer in isolation, innermost first, and read two numbers off each one.
The time tells you more than the code:
code=200and fast: that hop is healthy, move outward.code=000 time=0.03s: connection refused. Nothing is listening on that address and port, and the OS rejects the attempt immediately.code=000 time=10s: never completed. The connection black-holed and ate the full timeout. That is what a connect attempt to an address that silently drops packets looks like, which is what a disabled IPv6 listener looks like to a client on the wrong stack.
Innermost first. curl http://localhost:3001/ returned an instant 200. The node app was healthy, so the fault was upstream of it, in the proxy layers.
Then Caddy. Probing it on 8443 refused fast: code=000 in about 37 milliseconds. Caddy was listening only on the IPv6 wildcard ::, so the v4 side of the port had nothing bound to it. That is the classic code=000 time=0.03s shape.
One more command earned its place here:
Get-NetTCPConnection -LocalPort 8443We left the -State filter off on purpose. Without it, every listener on that port shows, so you can see at a glance which stack is bound, v4 or v6. Caddy was on v6 only, which confirmed what the curl refusal implied.
There is also an SNI trap at this layer. If you point curl at https://127.0.0.1:8443 against a name-vhosted server, the handshake can drop because the SNI does not match a site block, and you will misread that as a connection failure. You can force the SNI while still routing to a literal IP:
curl --resolve myapp.example.com:8443:127.0.0.1 https://myapp.example.com:8443/That sends the real hostname in SNI but dials the IP you told it to. It separates a routing problem on the name from a listener bound to the wrong stack.
Pinning the wrong hop
By now Caddy looked guilty, so we pinned it. We set default_bind 127.0.0.1 and changed reverse_proxy localhost:3001 to reverse_proxy 127.0.0.1:3001. Both are good hygiene, and the site was still hanging.
For myapp.example.com, Caddy was not in the request path at all. IIS had a rewrite rule that proxied straight to the node app, and Caddy only sat in the certificate-renewal path. The Caddy edit was real cleanup and had no effect on the symptom.
The offender was the IIS rewrite rule in the site’s web.config, which forwarded to:
http://localhost:3001/{R:1}localhost. On this machine localhost resolves to ::1 before 127.0.0.1, so IIS tried the IPv6 loopback first, and the node app was listening on the IPv4 loopback. Before the morning change, that v6 attempt got refused instantly and fell through to v4, which is why nobody had ever noticed. After it, the v6 connect black-holed, ate the connect timeout, and only then fell back. Every request got ten seconds of dead air, on a path we had not suspected because we were staring at Caddy.
The fix was a few characters. Rewrite the upstream to the literal IPv4 loopback:
http://127.0.0.1:3001/{R:1}Then iisreset. The same public probe that had hung for the full ten seconds now came back in 18 milliseconds. The request reached IIS and got an answer fast. The answer happened to be a 503, because the iisreset stop had hung the app pool. That is a different failure, and it failed in milliseconds instead of seconds. After a separate app-pool cleanup the page served clean, around 50 milliseconds end to end.
So restarting IIS to apply a web.config change is not always clean. The timing was still readable: a 10s hang becoming an 18ms 503 was the IPv6 fix landing, even though the page was not done yet.
Why this hides so well
Nothing in this bug looks broken the way you expect. The app is up. The config is syntactically fine. localhost is not a typo. DNS is not misconfigured. Every layer, examined alone with the wrong tool, looks healthy.
What changed is invisible: the resolution order of a name. localhost resolves to a list, and on Windows that list puts ::1 ahead of 127.0.0.1. Multiply that across three services that each proxy to localhost on their own and any hop can be the ten seconds. You cannot tell which one without measuring.
A refused connection fails in tens of milliseconds. A black-holed connection fails in whole seconds. code=000 with a 10-second time is a different animal from code=000 at 30 milliseconds, and the gap between them points at the hop that is lying to you. That is why -o NUL -w "code=%{http_code} time=%{time_total}s" is worth memorizing.
After any IP stack change
Re-test every service that reaches another service by the name localhost. The name can now resolve to an address nothing is listening on, and the only thing that separates a refusal from a black hole is the clock.