When making multiple HTTP requests to the same host, creating a new connection for each request is highly inefficient. This process involves a three-way TCP handshake, potential TLS negotiation, and then a tear-down for every single operation. HTTP sessions solve this problem by maintaining a pool of persistent connections that can be reused for multiple requests, dramatically reducing latency and overhead. The httpx library provides a powerful Client object to manage these sessions, offering connection pooling, cookie persistence, and shared configuration.

The Client Object and Connection Pooling

The httpx.Client is the cornerstone of session management. Upon instantiation, it creates a connection pool—a collection of pre-established connections kept alive and ready for use. When you make a request using the client, it first checks the pool for an available connection to the target origin. If one exists, it is reused immediately. If not, a new connection is created, added to the pool, and used for the request. This pool has a configurable limit to prevent overwhelming a server with too many simultaneous connections.

import httpx

# Create a client session. This initializes the connection pool.
with httpx.Client() as client:
    # First request: establishes a new connection to the server.
    response1 = client.get('https://httpbin.org/json')
    print(f"First request: {response1.status_code}")

    # Second request to the same origin: REUSES the existing connection.
    response2 = client.get('https://httpbin.org/xml')
    print(f"Second request: {response2.status_code}")

# The 'with' block automatically closes the client and all connections in the pool.

The key advantage here is performance. The second request avoids the entire connection setup overhead. This benefit compounds significantly when making dozens or hundreds of requests. The connection pool is thread-safe, meaning a single Client instance can be safely used across multiple threads.

Setting Persistent Headers and Cookies

A session is also crucial for maintaining consistent state across requests. Instead of manually adding the same Authorization header or custom User-Agent to every individual request, you can define them once at the session level. The Client will automatically attach these headers to every outgoing request made through it. Similarly, cookies set by the server in a response are automatically stored and sent back on subsequent requests to the same domain, managing your authentication state seamlessly.

import httpx

# Headers and auth set here are applied to every request made by this client.
persistent_headers = {'User-Agent': 'MyApp/1.0.0', 'X-Custom-Data': 'value123'}
auth = ('my_username', 'my_password')

with httpx.Client(headers=persistent_headers, auth=auth) as client:
    # This request includes the persistent headers and auth.
    response = client.get('https://httpbin.org/headers')
    returned_headers = response.json()['headers']
    print(f"User-Agent sent: {returned_headers.get('User-Agent')}")
    print(f"Authorization sent: {returned_headers.get('Authorization')}")

    # A request to a login endpoint might set a cookie.
    login_resp = client.post('https://example.com/login', data={'user': 'name'})
    # The session now stores the cookie. Subsequent requests will send it automatically.
    dashboard_resp = client.get('https://example.com/dashboard')

Configuration and Pool Limits

The behavior of the connection pool is highly configurable. The most important parameter is limits, which controls the number of connections allowed. It is an instance of httpx.Limits that lets you set the max_keepalive_connections (how many idle connections to keep in the pool) and the max_connections (the absolute total for the entire pool). Setting appropriate limits is critical for both client and server health.

import httpx

# Configure a client with a custom connection pool policy.
custom_limits = httpx.Limits(max_keepalive_connections=5, max_connections=20)
timeout = httpx.Timeout(10.0)  # A default timeout for all requests.

with httpx.Client(limits=custom_limits, timeout=timeout) as client:
    # This client will maintain up to 5 idle connections and allow a maximum
    # of 20 simultaneous connections across all hosts.
    responses = [client.get(f"https://httpbin.org/delay/{i}") for i in range(3)]

Common Pitfalls and Best Practices

  1. Always Use a Context Manager (with): This is the most critical practice. The context manager ensures the client is properly closed, gracefully closing all open connections in the pool. Failing to close the client can lead to resource warnings and port exhaustion.

    # GOOD: Connections are always closed.
    with httpx.Client() as client:
        client.get(...)
    
    # BAD: The client and its connections remain open until garbage collection.
    client = httpx.Client()
    client.get(...)
    # You must explicitly call client.close()
    
  2. Don’t Create a New Client for Every Request: This anti-pattern completely negates the benefit of connection pooling and will drastically slow down your application. Create one client per host (or set of hosts) and reuse it.

  3. Be Mindful of State in Long-Lived Sessions: Cookies and headers persist for the lifetime of the client. If your application logic requires a “fresh” state (e.g., logging out one user and logging in as another), you must create a new client session.

  4. Configure Timeouts: Always set timeouts at the client level to avoid hanging indefinitely on network issues. The Timeout object allows you to set separate timeouts for connect, read, write, and pool operations.

  5. Understand the Limits: The default pool limits are conservative. For high-performance scraping or API consumption, you may need to increase max_connections. Conversely, to be a good citizen to a small API, you might want to lower them to avoid overwhelming the server.