Skip to content
ExponaBLOG
GO PROGRAMMING & PRODUCTION APIS · DAY 065 / 100

Timeouts for HTTP clients in Go: lessons from production

The default client has no timeout.

Expona Admin7 min read

Timeouts for HTTP clients in Go: lessons from production

We've all been there: a downstream service hangs, and suddenly your entire application grinds to a halt, burning through goroutines and memory until it crashes. The default HTTP client in Go has no timeout, which means requests will wait indefinitely by default. In this article, we'll walk through the practical patterns we use in production to keep HTTP clients responsive and your systems reliable.

Why timeouts matter

Without timeouts, a single slow or unresponsive dependency can cascade into system-wide failure. Imagine you're running an e-commerce platform that calls an inventory service. If that service becomes unresponsive for 30 minutes, and your HTTP client has no timeout, you'll spawn thousands of goroutines all waiting for a response that never comes. Memory usage spikes, new requests queue up, and eventually your process crashes or becomes unresponsive to other clients.

Timeouts are a fundamental defense mechanism. They allow your application to fail fast, shed load, and recover. A timeout tells the underlying network layer to give up after a certain duration, freeing resources immediately.

Set http.Client{Timeout: ...}

The simplest and most common pattern is to set a timeout when creating an http.Client. This timeout applies to the entire request-response cycle: DNS lookup, connection establishment, sending the request, and receiving the response.

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

resp, err := client.Get("https://api.example.com/data")
if err != nil {
    log.Printf("request failed: %v", err)
    return err
}
defer resp.Body.Close()

This is your first line of defense. We typically set timeouts between 5 and 30 seconds, depending on the SLA of the downstream service. For a payment gateway, you might use 30 seconds. For an internal metrics service, 5 seconds is often reasonable.

The key insight: a single timeout value covers the entire request. If DNS takes 1 second, establishing the connection takes 2 seconds, and sending the request takes 1 second, you've used 4 seconds already. The server only has 1 second to respond before the timeout fires.

In production, we often make this configurable via environment variables or a config file:

go
type HTTPConfig struct {
    Timeout time.Duration
}

func newClient(cfg HTTPConfig) *http.Client {
    return &http.Client{
        Timeout: cfg.Timeout,
    }
}

This lets you tune timeouts without redeploying. Critical integrations might warrant a longer timeout; less important enrichment calls might use a shorter one.

Use context deadlines per request

Sometimes you need finer control. Imagine a user request handler that calls three downstream services. You want to allocate a total budget—say, 2 seconds—across all three calls. With just a client-level timeout, you can't express this easily.

Context deadlines solve this problem. They propagate a deadline through your call chain and allow you to enforce a cumulative timeout across multiple operations:

go
func handleUserRequest(w http.ResponseWriter, r *http.Request) {
    ctx, cancel := context.WithTimeout(r.Context(), 2 * time.Second)
    defer cancel()

    // Call service A
    userResp, err := callUserService(ctx, client)
    if err != nil {
        http.Error(w, "user service timeout", http.StatusGatewayTimeout)
        return
    }

    // Call service B—shares the same 2-second budget
    productsResp, err := callProductService(ctx, client)
    if err != nil {
        http.Error(w, "product service timeout", http.StatusGatewayTimeout)
        return
    }

    // ... use responses
}

When you use req.WithContext(ctx) on an HTTP request, Go respects the context deadline. If the context exceeds its deadline before the request completes, the request is cancelled:

go
func callUserService(ctx context.Context, client *http.Client) (*http.Response, error) {
    req, err := http.NewRequestWithContext(ctx, "GET", "https://api.example.com/user", nil)
    if err != nil {
        return nil, err
    }
    return client.Do(req)
}

Notice we pass ctx directly to NewRequestWithContext. This is critical. Without it, the context deadline is ignored.

A best practice: combine both the client timeout and context deadlines. The client timeout is a safety net; the context deadline gives you fine-grained control:

go
client := &http.Client{
    Timeout: 10 * time.Second, // safety net
}

ctx, cancel := context.WithTimeout(context.Background(), 2 * time.Second) // request-level budget
defer cancel()

req, _ := http.NewRequestWithContext(ctx, "GET", "https://api.example.com/data", nil)
resp, err := client.Do(req)

In this setup, whichever timeout fires first wins. If the context deadline is 2 seconds and the client timeout is 10 seconds, the request fails at 2 seconds.

Reuse clients for connection pooling

A subtle but important mistake: creating a new http.Client for every request. Each client maintains its own connection pool. Creating a new client for each request defeats connection reuse and can exhaust file descriptors on busy systems.

Instead, create a client once and reuse it across your entire application:

go
var httpClient = &http.Client{
    Timeout: 5 * time.Second,
}

func init() {
    httpClient.Transport = &http.Transport{
        MaxIdleConns:        100,
        MaxIdleConnsPerHost: 10,
        IdleConnTimeout:     90 * time.Second,
    }
}

This single client is safe to use concurrently from multiple goroutines. The underlying http.Transport manages a pool of TCP connections, reusing them across requests to the same host.

In a web server context, we typically initialize the client at package level or in a dependency injection container:

go
type Service struct {
    httpClient *http.Client
    baseURL    string
}

func NewService(baseURL string) *Service {
    return &Service{
        httpClient: httpClient,
        baseURL:    baseURL,
    }
}

func (s *Service) FetchData(ctx context.Context) ([]byte, error) {
    req, _ := http.NewRequestWithContext(ctx, "GET", s.baseURL + "/data", nil)
    resp, err := s.httpClient.Do(req)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()
    return io.ReadAll(resp.Body)
}

The Transport settings above are sensible defaults for most applications. MaxIdleConns caps the total number of idle connections. MaxIdleConnsPerHost limits connections to a single host. These prevent resource leaks while maintaining good connection reuse.

Close response bodies

This seems obvious, but we see it missed regularly in production code. Every http.Response has a body that must be closed, even if you don't read it or if an error occurs:

go
// Wrong: resource leak if the request succeeds but we return early
resp, err := client.Get(url)
if err != nil {
    return err
}
if resp.StatusCode != 200 {
    return fmt.Errorf("unexpected status: %d", resp.StatusCode)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)

The problem: we defer the close, but if status code is not 200, we return before reading the body. The response body stays open.

The fix: close the body immediately after checking the error, before any other early returns:

go
resp, err := client.Get(url)
if err != nil {
    return err
}
defer resp.Body.Close()

if resp.StatusCode != 200 {
    return fmt.Errorf("unexpected status: %d", resp.StatusCode)
}

body, _ := io.ReadAll(resp.Body)

Now the defer runs regardless of the status code. The body is always closed and the connection is returned to the pool.

If you're making many requests in a loop, unclosed bodies accumulate and exhaust your file descriptor limit, causing subsequent requests to fail with "too many open files".

Common mistakes

  • No timeout at all: Relying on the OS or upstream to timeout requests. Go's default client has no timeout, so you must set one explicitly.
  • Timeout too short: A 1-second timeout to a service that often takes 2 seconds under load. Measure SLAs and leave headroom.
  • Creating a new client per request: Wasting connection pooling and file descriptors. Create one client and reuse it.
  • Ignoring context cancellation: Using http.NewRequest instead of http.NewRequestWithContext. The context deadline won't be honored.
  • Not closing response bodies: Especially in error paths. Always defer resp.Body.Close() immediately after checking the error.
  • Setting timeout but not respecting it locally: If you set a 5-second client timeout but also set a 10-second context deadline, the client timeout will trigger first, confusing debugging efforts. Be explicit about which timeout applies where.

Key takeaways

  • Always set a timeout on your http.Client. The default has none, which is unsafe in production.
  • Use context deadlines for request-level control and cumulative timeout budgets across multiple calls.
  • Reuse a single http.Client instance across your application to benefit from connection pooling.
  • Close response bodies immediately after checking the error, before any early returns.
  • Make timeout values configurable so you can tune them post-deployment without code changes.
  • Test timeout behavior explicitly—verify that your application fails fast and recovers gracefully when a downstream service is slow.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles