Go's default HTTP client has no overall request timeout. Learn how client, context, and transport timeouts work together without breaking long-running responses.
· 5 min read

The default Go HTTP client can wait forever. Here's how to fix it.


Go’s http.DefaultClient has no overall request timeout. If a server accepts a connection but never returns response headers, a request made with http.Get can wait until something else cancels it or the connection fails.

That does not mean every part of the default stack is unbounded. DefaultClient uses http.DefaultTransport, which includes dial and TLS handshake timeouts. What it lacks is a deadline for the request as a whole, including the response body.

There is no single timeout value that suits every application, but production clients should have an explicit cancellation policy. Go gives you three layers to build one:

  • http.Client.Timeout for a simple end-to-end limit
  • context.Context for a deadline or cancellation tied to one request
  • http.Transport fields for individual network phases

Start with Client.Timeout

For a typical API call, an overall timeout is the simplest useful guardrail:

client := &http.Client{
	Timeout: 10 * time.Second,
}

resp, err := client.Get("https://api.example.com/data")
if err != nil {
	log.Fatal(err)
}
defer resp.Body.Close()

Client.Timeout covers connection setup, redirects, and reading the response body. Its timer keeps running after Get or Do returns, so it can interrupt a body read too. A zero value means no client-level timeout.

That end-to-end behaviour is often exactly what an API call needs. It is less suitable for a large download that may legitimately stream for longer than a fixed wall-clock limit. In that case, use phase-specific timeouts and a request deadline chosen for that operation. Bear in mind that the standard transport does not provide an idle timeout for reading a response body.

Use a context for each request

An outgoing request’s context controls its full lifetime: obtaining a connection, sending the request, and reading the response headers and body.

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

req, err := http.NewRequestWithContext(
	ctx,
	http.MethodGet,
	"https://api.example.com/data",
	nil,
)
if err != nil {
	log.Fatal(err)
}

resp, err := client.Do(req)
if err != nil {
	if errors.Is(err, context.DeadlineExceeded) {
		log.Print("request deadline exceeded")
	}
	log.Fatal(err)
}
defer resp.Body.Close()

Contexts are useful when different calls need different deadlines or when several operations should be cancelled together. Inside an HTTP handler, derive the outgoing request from the incoming request’s context so client disconnects and upstream deadlines propagate naturally:

req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, url, nil)

If both the request context and Client.Timeout have deadlines, the earlier one ends the request.

Set timeouts for individual phases

http.Transport manages connections and exposes more targeted controls. Start by cloning DefaultTransport: this retains useful defaults such as proxy support, HTTP/2 negotiation, and its existing connection settings.

transport := http.DefaultTransport.(*http.Transport).Clone()
transport.DialContext = (&net.Dialer{
	Timeout:   3 * time.Second,
	KeepAlive: 30 * time.Second,
}).DialContext
transport.TLSHandshakeTimeout = 5 * time.Second
transport.ResponseHeaderTimeout = 10 * time.Second
transport.IdleConnTimeout = 90 * time.Second
transport.MaxIdleConns = 100
transport.MaxIdleConnsPerHost = 20
transport.MaxConnsPerHost = 50

client := &http.Client{
	Transport: transport,
	Timeout:   30 * time.Second,
}

These values are examples, not universal recommendations. Choose them from your service’s latency budget and the behaviour of its dependencies.

Here is what each setting controls:

  • net.Dialer.Timeout limits connection setup. When name resolution is required, it is included in the dial operation.
  • TLSHandshakeTimeout limits the TLS handshake after a TCP connection has been established.
  • ResponseHeaderTimeout starts after the request, including its body, has been fully written. It limits the wait for response headers but not the response body.
  • IdleConnTimeout closes keep-alive connections that have sat unused in the pool. It is a pool-lifetime setting, not a request timeout or a leak detector.

If you need to limit how long a client spends uploading a request body, note that ResponseHeaderTimeout does not cover that phase. Use an overall client or context deadline.

Connection limits are not timeout limits

The default transport keeps up to 100 idle connections in total and two idle connections per host. Those are limits on connections waiting to be reused, not limits on concurrent requests.

By default, MaxConnsPerHost is zero, which means there is no transport-level cap on the total number of dialing, active, and idle connections per host. If you set a positive cap, additional dials wait when the cap is reached. Those waiting requests still need a context or client timeout so they cannot queue without a deadline.

For a busy downstream, these two fields serve different purposes:

  • MaxIdleConnsPerHost controls how many completed connections can remain ready for reuse.
  • MaxConnsPerHost places backpressure on the total number of connections to that host.

Tune both from measurements rather than matching them mechanically to request concurrency. HTTP/2 can multiplex many requests over one connection, so connection counts do not always map one-to-one to in-flight requests.

Close response bodies—and read them when you want reuse

When Do succeeds, the caller must close resp.Body. If you want the underlying connection to be eligible for reuse, read the body to EOF as well as closing it:

resp, err := client.Do(req)
if err != nil {
	return err
}
defer resp.Body.Close()

if _, err := io.Copy(io.Discard, resp.Body); err != nil {
	return err
}

Do not blindly drain an unexpectedly large body. For responses you intend to discard, consider applying a size limit or closing early and accepting that the connection may not be reused.

A practical client constructor

Putting the pieces together gives you a reusable client with an overall safety net and phase-specific limits:

func NewHTTPClient() *http.Client {
	transport := http.DefaultTransport.(*http.Transport).Clone()
	transport.DialContext = (&net.Dialer{
		Timeout:   3 * time.Second,
		KeepAlive: 30 * time.Second,
	}).DialContext
	transport.TLSHandshakeTimeout = 5 * time.Second
	transport.ResponseHeaderTimeout = 10 * time.Second
	transport.IdleConnTimeout = 90 * time.Second
	transport.MaxIdleConnsPerHost = 20
	transport.MaxConnsPerHost = 50

	return &http.Client{
		Transport: transport,
		Timeout:   30 * time.Second,
	}
}

Reuse the client rather than constructing one for every request; transports maintain connection pools and are safe for concurrent use.

The important part is not copying these exact durations. It is deciding where every outgoing request gets its deadline, then making that decision visible in the code. For most JSON APIs, a bounded Client.Timeout plus propagated request contexts is a good starting point. Add transport-level settings when you need more precise failure handling or connection backpressure.