In the morning I disabled IPv6 on my Windows box and a class of page-load failures went away. That evening the entire site started hanging for ten seconds before it would load, and the cause was the exact change I had made twelve hours earlier. localhost resolving to ::1 first had quietly broken a three-hop reverse-proxy chain, and curl with timing flags walked the chain hop by hop until the dead link fell out.
The morning fix
Earlier that day I had been chasing a different ghost: Cloudflare-fronted sites like Mixcloud would stall in both browsers while the network looked perfectly healthy on paper. Pings were clean, DNS resolved fast, TCP handshakes completed. The real 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, so 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. I chose 0x20 over 0xFF deliberately so Tailscale’s IPv6 kept working. The morning fix is its own post. What matters here is that after it landed, anything on this box still reaching for IPv6 was reaching into a deprioritized stack that could black-hole instead of answering. Hold that thought.
The evening symptom
Late that evening I was debugging a “session not found” error on one of my 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. Meanwhile hitting the box on localhost was instant. So the app was alive; 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)Three places a request can die. The temptation is to guess which one, but walking each hop with a timed probe is faster than any guess.
Walking the chain with curl
The single tool that carried this entire debug was curl with 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 you forever. With that template you can 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. The failure is instant because the OS rejects it immediately.code=000 time=10s: never completed. The connection black-holed and ate the full timeout. That is the signature of a connect attempt going to an address that silently drops packets, which is exactly what a disabled IPv6 listener looks like to an IPv4 client and vice versa.
Innermost first. curl http://localhost:3001/ returned an instant 200. The node app was healthy. So the rot was upstream of the app, in the proxy layers.
Then Caddy. This is where the IPv6 change announced itself. Probing Caddy 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. Instant refusal, classic code=000 time=0.03s shape.
One more tool earned its place here:
Get-NetTCPConnection -LocalPort 8443No -State filter on purpose. Leaving the filter off shows every listener on that port, so you can see at a glance which stack is actually bound, v4 or v6. Caddy was on v6 only. That confirms what the curl refusal implied instead of leaving it as a hunch.
There is also an SNI trap waiting 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. The way out is to 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 “is the name routing wrong” from “is the listener on the wrong stack.”
The obvious culprit was a red herring
At this point Caddy looked guilty, so I pinned it: default_bind 127.0.0.1 and changed reverse_proxy localhost:3001 to reverse_proxy 127.0.0.1:3001. Both good hygiene. But 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. So the Caddy edit was real cleanup, but it was not the fix. I had pinned the wrong hop.
The actual 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. 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. Ten seconds of dead air on every single request, on a path I had not even suspected because I was 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. That number is the proof: the IPv6 black hole was gone, 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, but that is a different failure entirely and it failed in milliseconds, not 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. Read the timing anyway: a 10s hang becoming an 18ms 503 is the IPv6 fix landing, even though the page is not done yet.
Why this hides so well
The reason this bug is nasty is that nothing in it is broken in the way you expect “broken” to look. The app is up. The config is syntactically fine. localhost is not a typo. DNS is not misconfigured. Every layer, examined on its own with the wrong tool, looks healthy.
What changed is invisible: the resolution order of a name. localhost is not an address, it is a name that resolves to a list, and on Windows that list puts ::1 ahead of 127.0.0.1. Multiply that across three services that each independently proxy to localhost and you get a chain where any hop can be the ten seconds, and 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. The time is the diagnosis. Refusal is milliseconds, a black hole is seconds, and the gap between them is the hop that is lying to you. That is the whole reason -o NUL -w "code=%{http_code} time=%{time_total}s" is worth memorizing.
Related
- “My internet feels unstable” was a half-disabled IPv6 stack, not the ISP: the morning fix that caused this evening’s problem
- My whole DNS provider split died instantly: keep a second DoH provider on hand: another case where a name resolves unexpectedly after a network change
- “Bamboo is broken” was wrong: a deploy that races the filesystem under CPU pressure: another multi-hop failure that looks healthy at each step individually
- UTC logs, a local clock, and the canary request: timezone discipline in an incident: using timing data to isolate which hop in a chain is broken
- An Off-Origin Caddy Relay for Bare-Domain SSL (Because Cloudflare Pro Won’t Proxy Your Apex): Caddy proxy binding decisions that matter on the same stack