http.Get and http.DefaultClient have no timeout. Not a long one — none. A server that accepts your connection and then says nothing will hold that goroutine until the process dies.
This is the single most common production bug in Go services that call other services, and it is entirely invisible in development because your test server is fast and local.
It shows up when a dependency degrades. Not fails — degrades. A failing dependency returns an error and your code handles it. A slow one accepts the connection and holds it, goroutines accumulate, memory climbs, and eventually the whole service falls over because of a partner API nobody was watching.
Table of contents
- The default client and why it is a trap
- A client with a total timeout
- Granular timeouts on the Transport
- Reuse the client, and always close the body
- Per-request deadlines with context
- Retries, and not making things worse
- Where the timeouts have to agree
- How this fits the rest of the stack
- FAQ
The default client and why it is a trap
These two lines are equivalent, and both are dangerous:
resp, err := http.Get("https://api.example.com/data")
resp, err := http.DefaultClient.Get("https://api.example.com/data")
http.DefaultClient is a package-level &http.Client{} with a zero Timeout field, and zero means no limit. The documentation says so; almost nobody reads that far before shipping.
There is a second problem with the default client that matters at scale: it is shared package-wide. Any library in your dependency tree can reach in and mutate it. Configuring http.DefaultClient.Timeout in your main affects every library in the process, and a library doing the same affects you.
The rule is simple. Never use http.Get, http.Post or http.DefaultClient in code that runs in a service. Construct your own client.
A client with a total timeout
The minimum viable fix is one field:
client := &http.Client{
Timeout: 10 * time.Second,
}
resp, err := client.Get("https://api.example.com/data")
if err != nil {
// includes "context deadline exceeded" on timeout
return fmt.Errorf("fetch data: %w", err)
}
defer resp.Body.Close()
Client.Timeout covers the entire request lifecycle — connection, TLS handshake, headers, and crucially the reading of the response body. That last part catches people: the timer is still running while you read, so a client timeout of 10 seconds will kill a legitimate large download that takes eleven.
Reasonable starting points, to be adjusted with measurements rather than kept as gospel:
- Health checks and internal fast paths: 2-5s
- Ordinary API calls: 10-30s
- Background jobs that can afford to wait: 60s with retries
- Large downloads or streaming: do not use
Client.Timeoutat all — see below
Granular timeouts on the Transport
One total timeout is a blunt instrument. It cannot distinguish a server that never accepts the connection from one that accepts instantly and streams slowly. The Transport splits those apart:
transport := &http.Transport{
DialContext: (&net.Dialer{
Timeout: 5 * time.Second, // TCP connect
KeepAlive: 30 * time.Second,
}).DialContext,
TLSHandshakeTimeout: 5 * time.Second,
ResponseHeaderTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
IdleConnTimeout: 90 * time.Second,
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
}
client := &http.Client{
Transport: transport,
Timeout: 30 * time.Second,
}
ResponseHeaderTimeout is the valuable one. It bounds how long you wait for the server to start responding without bounding how long the body takes to arrive — which is exactly the distinction a total timeout cannot make. Set it and you can drop Client.Timeout for streaming endpoints while still failing fast on dead servers.
MaxIdleConnsPerHost deserves attention too. It defaults to 2, which is low for a service hammering one upstream. Connections beyond that are closed after each request and re-established on the next, so you pay a TCP handshake and a TLS handshake per call. Raising it to 10-100 for a hot upstream is often a larger latency win than anything else in this post.
Reuse the client, and always close the body
Two mistakes that undo everything above.
Creating a client per request. http.Client is safe for concurrent use and holds the connection pool. Constructing a new one per call throws that pool away every time, so every request opens a fresh connection. Build one client at startup, store it on your struct, and share it.
Not draining the body. Everyone knows to defer resp.Body.Close(). Fewer know that a connection is only returned to the pool if the body was read to EOF:
defer func() {
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
}()
Without the drain, code that closes early — after checking a status code, or after decoding only part of the response — leaks a connection per call. It looks like a connection pool that never warms up, and it is.
Also note that on error, resp is nil, so defer resp.Body.Close() before the error check will panic. Check the error first.
Per-request deadlines with context
Client.Timeout is a property of the client, so it applies uniformly. When one call needs a different budget, or when a deadline arrives from an inbound request, use context:
func fetch(ctx context.Context, client *http.Client, url string) ([]byte, error) {
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer func() { io.Copy(io.Discard, resp.Body); resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status %d", resp.StatusCode)
}
return io.ReadAll(resp.Body)
}
Propagating the caller’s context is what makes cancellation work end to end. When the inbound HTTP request is abandoned, its context is cancelled, and the outbound call you made on its behalf is cancelled too — no work continues for a client who left. Whichever deadline is tighter, the client’s or the context’s, wins.
Always use http.NewRequestWithContext rather than http.NewRequest. There is no reason to build a request that cannot be cancelled.
Retries, and not making things worse
Retrying is where well-intentioned clients turn a degraded dependency into an outage. A few rules that keep it safe:
- Only retry idempotent requests. GET, PUT and DELETE are usually safe. Retrying a POST can create two orders.
- Only retry the right failures. Timeouts, connection errors, 502, 503, 504, and 429. A 400 will be a 400 next time too.
- Back off exponentially with jitter. Fixed-interval retries from many instances synchronise into a thundering herd against a service that is already struggling.
- Cap total attempts and total time. Three attempts inside the caller’s deadline, not five attempts regardless.
- Honour Retry-After on 429 and 503 when the server sends it.
And add a circuit breaker before adding more retries. When an upstream has failed twenty times in a row, the useful behaviour is to stop calling it for a while and fail fast, not to keep queueing goroutines against it.
Where the timeouts have to agree
Client timeouts are only half the picture. The server side has ReadTimeout, WriteTimeout and IdleTimeout, and any proxy, ingress or load balancer in between has its own. When these disagree, you get confusing failures: a client timing out at 30s in front of a server willing to wait 60s produces work that completes and is thrown away.
The rule of thumb is that timeouts should decrease as you move inward. The edge waits longest; each hop inward waits less. Then a slow inner call surfaces as a clean inner timeout rather than a truncated connection at the edge.
Go services with these settings deploy on RunxBuild straight from a repository — a build log, a live route, runtime logs and rollback to the previous deploy — with a managed Postgres or MySQL available on the same platform when the service needs state behind it.
How this fits the rest of the stack
Almost every Go HTTP outage traces back to a missing timeout, a client constructed per request, or a body that was never drained. Build one client at startup with explicit transport timeouts, propagate context for per-call deadlines, and retry narrowly. Once the service is talking to a database and an upstream API, the running cost is the service plan plus whatever it depends on — the RunxBuild hosting calculator puts those line items on one page.
Useful related references:
- Build a Python VPN Client: When It Makes Sense and When It Doesn’t
- Generate SSH Keys on Windows: OpenSSH Client and PowerShell
- How to Create an SSH Key in Windows: OpenSSH Client and PowerShell
- Services on RunxBuild
FAQ
Does http.Get have a timeout in Go?
No. http.Get uses http.DefaultClient, whose Timeout field is zero, and zero means no limit. A server that accepts the connection and never responds will block that goroutine indefinitely. Always construct your own client with an explicit timeout.
What is a good HTTP client timeout in Go?
It depends on the call: 2-5s for health checks, 10-30s for ordinary API calls, 60s with retries for background work. For large downloads, use ResponseHeaderTimeout on the transport instead of a total timeout, since Client.Timeout also covers reading the body.
Should I create a new http.Client per request?
No. http.Client is safe for concurrent use and holds the connection pool, so creating one per request discards that pool and forces a new TCP and TLS handshake every call. Build one at startup and share it.
Why do I need to drain the response body?
A connection is only returned to the pool after the body is read to EOF. Closing early — after checking a status code, for instance — leaks a connection per call. Use io.Copy(io.Discard, resp.Body) before closing.
What is the difference between Client.Timeout and context deadlines?
Client.Timeout applies uniformly to every request from that client. A context deadline is per-request and propagates cancellation from the caller, so abandoning an inbound request cancels the outbound calls made for it. Whichever is tighter wins.