The Default Go HTTP Client Will Wait Forever
Every Go tutorial has the same first HTTP example, and it has no timeout whatsoever. Something like this:
resp, err := http.Get("https://example.com")
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
It compiles, it runs, it fetches the page. And the missing timeout survives copy-paste into production code with alarming regularity.
http.Get calls through to http.DefaultClient, which is just &http.Client{}, a zero-value struct. Its Timeout field defaults to 0. In the net/http package, zero does not mean "sensible default", it means "no limit at all".
It half-works, which is why nobody notices
Try pointing http.Get at an unroutable address and it will fail after roughly 30 seconds. People reasonably conclude the client has some kind of built-in protection.
It does, but only for one specific phase. http.DefaultTransport dials with a 30 second connect timeout and a 10 second TLS handshake timeout. Those are properties of the Dialer and Transport, not the Client.
Once the TCP connection and TLS handshake succeed, you are on your own. If the server accepts the connection and then simply never writes a response, or writes one byte an hour, http.Get will block until the process is killed.
A silent server reproduces the hang in a few lines
It's easy to reproduce with nothing more than the standard library: a listener that accepts connections and then does nothing with them.
package main
import (
"fmt"
"net"
"net/http"
)
func main() {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
panic(err)
}
go func() {
for {
conn, err := ln.Accept()
if err != nil {
return
}
_ = conn // TCP handshake completes, then the server goes silent
}
}()
resp, err := http.Get("http://" + ln.Addr().String())
fmt.Println(resp, err) // this line never runs
}
Run that and it hangs forever. No error, no panic, nothing in the logs to say why. The dial succeeded, so the 30 second connect timeout never fires, and there's nothing else watching the clock.
In a service, one hung call becomes a slow-motion outage
A single hung http.Get in a script is an annoyance: ctrl-C and move on. The same bug in a service is a slow-motion outage. A goroutine blocked reading a response holds all of these, indefinitely:
- its stack
- its connection
- often a lock or a slot in some upstream worker pool
Now suppose that request is triggered per incoming request to your own service, and the downstream dependency degrades from "returns errors" to "accepts connections and stalls". That's a genuinely common failure mode. Typical causes:
- a database connection pool exhausted behind an API
- a reverse proxy under load
- a load balancer sending half-open TCP
You don't get a burst of errors you can alert on. You get quietly accumulating goroutines and open file descriptors until the process falls over. That's a much worse thing to be paged for at 3am than a clean 5xx.
Hang on, isn't this the context cancellation problem? No, and the difference is worth a moment. That's the one where context.Context cancellation doesn't stop a goroutine you wrote yourself. net/http's Transport does wire context deadlines into the connection properly, closing the underlying socket when the context is done. The problem here isn't that Go ignores your timeout. It's that nobody set one in the first place.
Three fixes, from blunt to fine-grained
1. A client-wide Timeout
The blunt but honest fix is to stop using http.DefaultClient and give every client a Timeout:
var httpClient = &http.Client{
Timeout: 15 * time.Second,
}
Be clear about what this covers. The documented behaviour is that Client.Timeout spans the whole exchange:
- connecting
- writing the request
- waiting for the response headers
- reading the entire response body
The clock keeps running after Do returns and will interrupt a slow read partway through resp.Body. That's fine for JSON APIs where the body is a few kilobytes.
It's the wrong tool for streaming a large file download. A deliberately slow-but-steady transfer will get killed at the 15 second mark regardless of progress.
2. A per-request context deadline
For most application code, the better default is a per-request context.Context deadline rather than a client-wide timeout, particularly because it composes with whatever deadline the caller already has:
func fetch(ctx context.Context, url string) (*http.Response, error) {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("building request: %w", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("fetching %s: %w", url, err)
}
return resp, nil
}
Say fetch is called from inside an HTTP handler that already has a request-scoped context with its own deadline. context.WithTimeout takes the earlier of the two automatically, so the downstream call can never outlive the request that triggered it.
That property is worth more than it looks. A slow dependency three layers down gets cut off by the same deadline the client saw. It doesn't carry on burning a goroutine after the original request has already timed out and been abandoned.
3. A Transport with a timeout per phase
For services that make a lot of outbound calls and need finer control than either of the above, build a Transport with explicit timeouts for each phase, rather than one number covering everything:
var httpClient = &http.Client{
Timeout: 15 * time.Second,
Transport: &http.Transport{
DialContext: (&net.Dialer{
Timeout: 5 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
TLSHandshakeTimeout: 5 * time.Second,
ResponseHeaderTimeout: 5 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
IdleConnTimeout: 90 * time.Second,
},
}
ResponseHeaderTimeout is the one that's missing from http.DefaultTransport, and it's usually the actual gap. It bounds how long you'll wait for the server to start responding after the request has been sent, independently of how long the connection or the body read is allowed to take.
Set it, and the "server accepts the connection and goes silent" scenario from the reproduction above fails fast instead of hanging.
Stop the pattern creeping back in
None of this is exotic once you know it exists, which is precisely the problem. It's not something the compiler or a basic test will catch, because a hung request looks identical to a slow one until it's too late.
golangci-lint's noctx linter flags calls to http.Get, http.Post, and http.NewRequest that don't carry a context. That's a cheap way to stop the pattern from creeping back into a codebase after you've fixed it once.
It won't catch a Client built with a bare &http.Client{} and no Timeout, though. Pair it with a lint rule or a code review habit that treats http.DefaultClient and http.Get as things you don't call directly outside a throwaway script.
The underlying lesson isn't really about HTTP. "The standard library will have sensible defaults" is a fair assumption for most of Go. It's a dangerous one specifically for anything involving unbounded waits on another process, because the only genuinely safe default there is to make the caller say how long they're willing to wait.