When the server takes too long
You deploy a service that calls an external payment gateway. Locally, the response arrives in two hundred milliseconds. In staging, the network adds a hundred milliseconds of latency. Suddenly your logs fill with net/http: request canceled (Client.Timeout exceeded). You did not change the code. The server did not crash. The request simply outlived its allowed lifespan.
This error is not a mystery. It is a boundary condition. Go's net/http client enforces a hard deadline on every outgoing request. When that deadline passes, the client tears down the connection and returns the error. The fix is rarely about changing a single number. It is about understanding what the timeout actually covers, how it interacts with Go's context system, and where to place the deadline in your architecture.
Set your deadlines before you open the socket. Let the context do the heavy lifting.
What the timeout actually controls
The Timeout field on http.Client is a blanket deadline. It covers the entire lifecycle of a single request. That includes DNS resolution, the TCP handshake, the TLS negotiation, sending the request headers and body, waiting for the server's response headers, and reading the response body. If any single step crosses the threshold, the client cancels the operation.
Think of it like a kitchen timer set for an entire meal. You set it for thirty minutes. If chopping vegetables takes twenty minutes, you only have ten minutes left for cooking. If the oven takes fifteen minutes to preheat, the timer rings while the food is still raw. The timer does not care which step is slow. It only cares that the total elapsed time exceeded the limit.
Go implements this by wrapping every request in a context.Context with a deadline. The context flows through the transport layer, the dialer, and the connection pool. When the deadline arrives, the context triggers a cancellation signal. The underlying network socket closes. The error bubbles back up to your code.
The http.Transport manages a pool of idle connections. Reusing connections saves CPU cycles and reduces latency. The timeout deadline applies to the logical request, not the physical connection. When a request times out, the transport marks the connection as broken and discards it. The next request will dial a fresh socket. This prevents corrupted state from leaking into subsequent calls.
Timeouts are circuit breakers. They protect your service from cascading failures.
A minimal example that triggers it
The default http.DefaultClient has no timeout. It will wait forever. That is convenient for scripts and dangerous for services. Setting a timeout is straightforward, but picking the wrong value triggers the error immediately.
package main
import (
"fmt"
"net/http"
"time"
)
// FetchSlowEndpoint demonstrates a request that exceeds the client timeout.
func FetchSlowEndpoint() {
// The client enforces a hard deadline on the entire request lifecycle.
client := &http.Client{
Timeout: 500 * time.Millisecond,
}
// This endpoint intentionally delays its response by two seconds.
resp, err := client.Get("https://httpbin.org/delay/2")
if err != nil {
// The error message tells you exactly why the request failed.
fmt.Printf("Request failed: %v\n", err)
return
}
// Always close the body to release the connection back to the pool.
defer resp.Body.Close()
fmt.Printf("Status: %s\n", resp.Status)
}
Running this prints Request failed: Get "https://httpbin.org/delay/2": context deadline exceeded (Client.Timeout exceeded). The client waited five hundred milliseconds. The server took two seconds. The context deadline fired first. The connection was dropped. The error surfaced.
The compiler will not warn you if the timeout is too short. It trusts your configuration. You must validate the duration against your service level objectives.
Measure your p99 latency. Set the timeout slightly above it.
Walking through the cancellation
When you call client.Get, the client does not immediately open a socket. It checks the connection pool first. If a reusable connection exists, it reuses it. If not, it dials a new one. The timeout deadline is attached to the context before any network activity begins.
The transport layer receives the context. It passes the context to the dialer for TCP connection. It passes the context to the TLS layer for handshake. It passes the context to the read and write operations. Every single network call checks the context for cancellation. When the deadline passes, the context returns an error. The transport catches that error, closes the underlying connection, and propagates the failure up the call stack.
This design prevents goroutine leaks. A request that hangs forever would block a goroutine indefinitely. The context deadline guarantees that every outgoing request eventually terminates. The tradeoff is that you must choose the deadline carefully. Too short, and you get false failures. Too long, and you waste goroutines waiting for dead servers.
Go's context package is designed for this exact pattern. The Done channel closes when the deadline arrives. Any goroutine listening on that channel receives a signal to stop. The http package wires this signal into the network layer automatically. You do not need to manually poll for time. You just check the error return value.
Context is plumbing. Run it through every long-lived call site.
Real-world configuration
In production, you rarely want a single Client.Timeout value. Different endpoints have different latency profiles. A database query might take ten milliseconds. A third-party webhook might take three seconds. A file upload might take thirty seconds. A blanket timeout forces you to pick the highest latency and accept that fast requests will sit idle waiting for the timer to expire.
The standard pattern is to leave Client.Timeout at zero and use context.WithTimeout per request. This gives you granular control. You can set a tight deadline for health checks and a generous deadline for batch operations. The client reuses connections efficiently because the timeout lives in the context, not the client configuration.
package main
import (
"context"
"fmt"
"io"
"net/http"
"time"
)
// CallExternalAPI demonstrates per-request timeout handling.
func CallExternalAPI(ctx context.Context, client *http.Client, url string) ([]byte, error) {
// Create a request that inherits the parent context.
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
// Execute the request. The context deadline controls the timeout.
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
// Defer body close to prevent connection leaks.
defer resp.Body.Close()
// Read the body within the same context deadline.
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read body: %w", err)
}
return body, nil
}
The convention in Go is to pass context.Context as the first parameter to any function that performs I/O. The parameter is almost always named ctx. Functions that accept a context must respect cancellation. If the context deadline fires while you are reading the response body, io.ReadAll will return an error. You should propagate that error immediately. Do not try to continue processing a partially read response.
The if err != nil { return err } pattern is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. You cannot accidentally swallow a timeout error when the check sits on its own line.
Wrap errors with %w. Trace the stack. Fail fast.
Pitfalls and silent failures
Setting timeouts correctly requires avoiding a few common traps. The first trap is disabling the timeout entirely. Setting Timeout: 0 tells the client to wait indefinitely. If the server hangs, your goroutine hangs. If you spawn a goroutine per request, you will exhaust your memory and crash. Always set a reasonable default, even if you override it with context later.
The second trap is confusing Client.Timeout with transport-level timeouts. The http.Transport struct has fields like DialContext, TLSHandshakeTimeout, and ResponseHeaderTimeout. These control specific phases. Client.Timeout covers everything. If you set both, the shorter one wins. The compiler will not warn you about overlapping deadlines. You will just get context deadline exceeded errors at unpredictable times.
The third trap is reading the response body after the timeout fires. When the context deadline passes, the client closes the connection. The resp.Body reader detects the closed connection and returns an error. If you ignore that error and try to parse the body, you will get malformed JSON or empty strings. The compiler complains with cannot use nil as []byte in assignment if you force a type conversion, but the real problem is the missing error check. Always check err after io.ReadAll or json.Unmarshal.
The fourth trap is goroutine leaks. If you start a background goroutine to process the response, that goroutine must also receive the context. If the request times out, the main function returns, but the background goroutine keeps running. It waits on a channel that never closes. It holds onto memory. It never gets garbage collected. The worst goroutine bug is the one that never logs. Always pass the context to background workers and cancel it when the request finishes.
Goroutines are cheap. Channels are not magic.
Picking the right timeout strategy
Timeouts are not one-size-fits-all. Your architecture dictates the right approach. Match the strategy to the workload.
Use Client.Timeout when you need a simple, catch-all deadline for a short-lived script or a CLI tool. It requires zero context plumbing and guarantees the program will not hang.
Use context.WithTimeout when you need per-request control in a long-running service. It lets you adjust deadlines based on the endpoint, the user tier, or the current load.
Use context.WithCancel when you need to abort a request from another goroutine. It gives you manual control over the deadline without a fixed duration.
Use a retry strategy with exponential backoff when the failure is likely transient. Network blips and load balancer resets recover quickly. Retrying with a growing delay prevents thundering herds and respects downstream rate limits.
Use a longer timeout when you are streaming large payloads or calling legacy systems with known latency. Compensate with a separate read deadline so the connection does not sit idle forever.
Trust the deadline. Let the context enforce it.