Go's Default HTTP Client Has No Timeout: The Footgun Every Tutorial Ships With
Every Go tutorial has the same first HTTP example. 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. It also has no timeout whatsoever, and that fact 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, and in the net/http package, zero does not mean "sensible default", it means "no limit at all".
The annoying part is that this half-works, which is exactly why it goes unnoticed. Try pointing http.Get at an unroutable address and it will fail after roughly 30 seconds, so 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.
Proving it
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.
Why this matters more than it looks like it should
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 its stack, its connection, and often a lock or a slot in some upstream worker pool, indefinitely. If that request is triggered per incoming request to your own service, and the downstream dependency degrades from "returns errors" to "accepts connections and stalls" (which is a genuinely common failure mode: 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, which is a much worse thing to be paged for at 3am than a clean 5xx.
This is a different failure to 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.
Fixing it: three levels of granularity
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,
}
Worth knowing exactly what this covers: the documented behaviour is that Client.Timeout spans the whole exchange, connecting, writing the request, waiting for the response headers, and 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 if you're streaming a large file download, because a deliberately slow-but-steady transfer will get killed at the 15 second mark regardless of progress.
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
}
If 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: it means a slow dependency three layers down gets cut off by the same deadline the client saw, rather than continuing to burn a goroutine after the original request has already timed out and been abandoned.
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 is 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.
Making the mistake hard to make again
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, which is 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, so it's worth pairing 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. It's that "the standard library will have sensible defaults" is a fair assumption for most of Go, and 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.