• English
  • Example: Reuse an HTTP Client

    In long-running services, creating a new http.Client for every request usually defeats connection reuse.

    A long-lived http.Client lets net/http reuse its Transport, connection pools, cookies, and other client state.

    Create the client once:

    type APIClient struct {
        client *http.Client
    }
    
    func NewAPIClient() *APIClient {
        return &APIClient{
            client: &http.Client{
                Timeout: 10 * time.Second,
            },
        }
    }

    Then reuse it:

    resp, err := c.client.Get(url)
    if err != nil {
        return err
    }
    defer resp.Body.Close()
    
    // Read or process the response.

    The important part is the lifetime:

    Create the client once, then reuse it.

    Client vs. Transport

    http.Client and http.Transport have different jobs.

    Your code
    
    
    http.Client
       ├── timeout
       ├── redirects
       └── cookies
    
    
    http.Transport
       ├── TCP connections
       ├── TLS
       ├── HTTP/1 keep-alive
       ├── HTTP/2
       └── connection pooling

    Client provides high-level request policy.

    Transport handles connection management. Transports are safe for concurrent use and are intended to be reused. The standard library explicitly recommends reusing transports rather than creating them as needed.

    Creating a new transport for every request defeats connection pooling:

    // Bad
    func fetch(url string) error {
        client := &http.Client{
            Transport: &http.Transport{},
        }
    
        resp, err := client.Get(url)
        if err != nil {
            return err
        }
        defer resp.Body.Close()
    
        return nil
    }

    The problem is not primarily the allocation cost of http.Client. The new transport has its own connection pool.

    Reuse Does Not Mean One Client for Everything

    A long-lived client should represent one set of client behavior and state.

    Separate clients can make sense when they need different:

    • timeouts
    • redirect policies
    • cookie jars
    • transports
    • authentication behavior

    Do not create a new client merely because requests are concurrent. http.Client is safe for concurrent use.

    Dependency injection is often useful when the client belongs to a particular component:

    type UserService struct {
        client *http.Client
    }
    
    func NewUserService(client *http.Client) *UserService {
        return &UserService{client: client}
    }

    Be careful when injecting a stateful client across unrelated components: its CookieJar and other client-level state are shared.

    The important question is ownership, not whether the variable is global.

    The Connection Pool Is the Point

    A reused Transport can keep connections available for later requests.

    For HTTPS, connection reuse can avoid repeatedly paying for:

    TCP connection
    
    TLS handshake
    
    HTTP request

    The exact reuse behavior depends on the protocol, server, response-body lifecycle, connection limits, and transport configuration.

    That is why this inside a request loop is usually a design mistake:

    client := &http.Client{
        Transport: &http.Transport{},
    }

    Create the transport once.

    Always Close the Response Body

    After a successful Do call, every execution path must eventually close resp.Body.

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

    Close it before checking the status code:

    resp, err := client.Do(req)
    if err != nil {
        return err
    }
    defer resp.Body.Close()
    
    if resp.StatusCode < 200 || resp.StatusCode >= 300 {
        return fmt.Errorf("unexpected status: %s", resp.Status)
    }

    Do not assume that returning because of a non-2xx status removes the obligation to close the body.

    The caller owns the response body after a successful request. Go's HTTP package documentation explicitly requires callers to close it when finished.

    Go 1.27 changed HTTP/1 response handling so that closing a response body can automatically drain some unread content, within conservative internal limits, to improve connection reuse. This is an implementation detail, not a replacement for closing the body.

    If an application wants to consume a bounded amount of remaining data explicitly, it can do so:

    _, _ = io.CopyN(io.Discard, resp.Body, 256<<10)
    _ = resp.Body.Close()

    This has a different purpose from simply calling Close: the application deliberately consumes up to a known number of bytes before closing.

    It can also make the amount of body data the application is willing to consume explicit when supporting multiple Go versions or transports. It does not guarantee connection reuse, and the read itself can still block according to the request's cancellation and timeout policy.

    Go 1.27's automatic post-close drain is specifically an HTTP/1 behavior. The implementation currently uses internal limits of 256 KiB and 50 ms, but those values are implementation details and should not be treated as API guarantees.

    MaxIdleConns vs. MaxIdleConnsPerHost

    These fields control different things:

    transport := &http.Transport{
        MaxIdleConns:        100,
        MaxIdleConnsPerHost: 20,
    }
    • MaxIdleConns — maximum idle connections across all hosts.
    • MaxIdleConnsPerHost — maximum idle connections retained for one host.
    • MaxConnsPerHost — maximum total connections for one host, including dialing, active, and idle connections.

    The default MaxIdleConnsPerHost is 2.

    The default MaxConnsPerHost is 0, which means no limit.

    Neither value means “maximum concurrent requests” by itself. In particular, MaxIdleConnsPerHost = 2 limits only how many idle connections are retained for reuse.

    For a service making heavy concurrent requests to one API, increasing MaxIdleConnsPerHost may improve connection reuse:

    transport.MaxIdleConnsPerHost = 20

    Use MaxConnsPerHost when you need to limit total connections to a host:

    transport.MaxConnsPerHost = 50

    Do not tune these values automatically. Measure connection churn, latency, and resource usage first.

    Configure a Transport Deliberately

    When you need custom transport settings, construct the transport explicitly:

    transport := &http.Transport{
        Proxy: http.ProxyFromEnvironment,
    
        DialContext: (&net.Dialer{
            Timeout:   30 * time.Second,
            KeepAlive: 30 * time.Second,
        }).DialContext,
    
        ForceAttemptHTTP2:     true,
        MaxIdleConns:          100,
        MaxIdleConnsPerHost:   20,
        IdleConnTimeout:       90 * time.Second,
        TLSHandshakeTimeout:   10 * time.Second,
        ExpectContinueTimeout: 1 * time.Second,
    }

    This makes the transport configuration owned by the application instead of depending on the mutable package-level http.DefaultTransport.

    http.DefaultTransport is an exported http.RoundTripper interface variable. The standard library initializes it with a *http.Transport, but application code can replace it with another RoundTripper. Therefore:

    http.DefaultTransport.(*http.Transport)

    contains a runtime type-assumption. If the global has been replaced with another implementation, the assertion panics.

    For application-owned production components, explicit construction avoids that dependency.

    Transport.Clone() is still useful when you intentionally want to start from an existing *http.Transport configuration:

    transport := existingTransport.Clone()
    transport.MaxIdleConnsPerHost = 20

    Clone deep-copies the transport's exported fields, including its TLSClientConfig. Function-valued fields such as DialContext are copied as function values, so a closure that captures mutable external state can still share that state between the original and the clone.

    One more detail:

    ForceAttemptHTTP2: true

    enables HTTP/2 attempts when the transport configuration would otherwise disable automatic HTTP/2 support. It does not guarantee that a connection will use HTTP/2; protocol negotiation and server support still determine the result.

    A Client Also Owns State

    For example, a client can own a CookieJar:

    jar, err := cookiejar.New(nil)
    if err != nil {
        return err
    }
    
    client := &http.Client{
        Jar: jar,
    }

    Cookies are then shared across requests made through that client.

    That can be exactly what you want for a session-aware client.

    It can also be a security problem if unrelated users or tenants share the same client and cookie jar.

    The same principle applies to other client-level policies:

    Reuse a client when you want to reuse its behavior and state.

    Do not share one stateful client across unrelated security contexts merely because connection pooling is convenient.

    Client.Timeout Still Applies to the Whole Request

    Reusing a client does not change timeout semantics.

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

    Client.Timeout covers the entire request lifecycle, including connection establishment, redirects, and reading the response body.

    If one operation needs a shorter deadline, use a request context:

    ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
    defer cancel()
    
    req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
    if err != nil {
        return err
    }
    
    resp, err := client.Do(req)
    if err != nil {
        return err
    }
    defer resp.Body.Close()

    The reusable client provides the base policy; the request context provides per-operation cancellation.

    Closing Idle Connections

    Long-lived clients normally keep idle connections available for reuse.

    When the owning component is shutting down, you can explicitly close them:

    client.CloseIdleConnections()

    This closes connections that are currently idle. It does not interrupt requests that are currently using connections.

    For a component with a clear client owner, exposing shutdown behavior can be useful:

    type APIClient struct {
        client *http.Client
    }
    
    func (c *APIClient) CloseIdleConnections() {
        c.client.CloseIdleConnections()
    }

    This is a shutdown concern, not something to call after every request.

    Common Mistakes

    Creating a client inside a request loop

    for _, url := range urls {
        client := &http.Client{}
        // ...
    }

    An empty http.Client uses http.DefaultTransport when its Transport field is nil. The client itself is new, while the default transport is shared.

    That still does not make creating a client per request a good pattern: the client represents request policy and state, and a long-running service normally benefits from a clear, reusable client lifetime.

    Creating a new Transport for every request

    This is worse:

    for _, url := range urls {
        client := &http.Client{
            Transport: &http.Transport{},
        }
        // ...
    }

    Each transport owns its own connection pool.

    Forgetting resp.Body.Close

    resp, err := client.Get(url)
    if err != nil {
        return err
    }
    
    if resp.StatusCode != http.StatusOK {
        return errUnexpectedStatus
    }
    
    // Body is never closed.

    Close the body immediately after a successful Do or convenience-method call.

    Treating MaxIdleConnsPerHost as a concurrency limit

    A value of 2 limits the number of idle connections retained per host. It does not cap active requests at two.

    Use MaxConnsPerHost when you actually need a total connection limit.

    Connection pooling is useful.

    Shared authentication or cookie state across security boundaries is not.

    A Practical Pattern

    For a service component, explicitly owning the transport makes the lifetime and configuration clear:

    type APIClient struct {
        client *http.Client
    }
    
    func NewAPIClient() *APIClient {
        transport := &http.Transport{
            Proxy: http.ProxyFromEnvironment,
    
            DialContext: (&net.Dialer{
                Timeout:   30 * time.Second,
                KeepAlive: 30 * time.Second,
            }).DialContext,
    
            ForceAttemptHTTP2:     true,
            MaxIdleConns:          100,
            MaxIdleConnsPerHost:   20,
            IdleConnTimeout:       90 * time.Second,
            TLSHandshakeTimeout:   10 * time.Second,
            ExpectContinueTimeout: 1 * time.Second,
        }
    
        return &APIClient{
            client: &http.Client{
                Transport: transport,
                Timeout:   10 * time.Second,
            },
        }
    }
    
    func (c *APIClient) Get(ctx context.Context, url string) (*http.Response, error) {
        req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
        if err != nil {
            return nil, err
        }
    
        resp, err := c.client.Do(req)
        if err != nil {
            return nil, err
        }
    
        // Caller owns resp.Body and must close it.
        return resp, nil
    }
    
    func (c *APIClient) CloseIdleConnections() {
        c.client.CloseIdleConnections()
    }

    The caller owns the returned response body:

    resp, err := api.Get(ctx, url)
    if err != nil {
        return err
    }
    defer resp.Body.Close()
    
    // Process the response.

    The ownership boundary is explicit: Get returns the response, so its caller is responsible for closing the body.

    Production Checklist

    Before shipping a long-lived HTTP client, ask:

    • Is the http.Client reused?
    • Is its Transport reused?
    • Is the timeout policy explicit?
    • Are request-level contexts used when an operation needs a deadline different from the client's base timeout?
    • Is every successful response body closed?
    • Does MaxIdleConnsPerHost match the actual workload?
    • Is MaxConnsPerHost needed to bound total connections?
    • Is CookieJar state shared intentionally?
    • If using Transport.Clone(), is the source transport intentionally owned and reused?
    • Does the owning component have a shutdown path for idle connections?

    The simplest reliable rule is:

    Create the client once. Reuse it. Close every response body. Treat client state as owned state.