backend / web frameworks / aiohttp / 01_clientsession_lifecycle.md

aiohttp — ClientSession Lifecycle and Connection Pooling

6 interview angles 5 min read source

aiohttp — ClientSession Lifecycle and Connection Pooling

The #1 production gotcha with aiohttp: creating a ClientSession per request instead of reusing one. Senior interviews always probe this.

The wrong pattern

async def fetch(url):
    async with aiohttp.ClientSession() as session:    # NEW session per call
        async with session.get(url) as resp:
            return await resp.text()

Looks clean. Catastrophic at scale:

  • Every call creates a new connection pool.
  • Every call opens new TCP+TLS connections (~100-300ms each on first call).
  • File descriptors leak under load (sessions cleaned up slowly).
  • DNS lookups repeated per call.

Symptoms in production:

  • High p99 latency despite low CPU.
  • “too many open files” errors.
  • Slow startup for warm-cache workloads.

The right pattern

One ClientSession per application, shared across requests:

class HTTPClient:
    def __init__(self):
        self.session: aiohttp.ClientSession | None = None

    async def start(self):
        connector = aiohttp.TCPConnector(
            limit=100,             # total connections across all hosts
            limit_per_host=30,     # per-host connection cap
            ttl_dns_cache=300,     # DNS cache TTL
            enable_cleanup_closed=True,
        )
        timeout = aiohttp.ClientTimeout(total=30, connect=5, sock_read=10)
        self.session = aiohttp.ClientSession(connector=connector, timeout=timeout)

    async def close(self):
        if self.session:
            await self.session.close()
            await asyncio.sleep(0.25)   # wait for SSL shutdown

http_client = HTTPClient()

In FastAPI, wire it through lifespan:

from contextlib import asynccontextmanager

@asynccontextmanager
async def lifespan(app: FastAPI):
    await http_client.start()
    yield
    await http_client.close()

app = FastAPI(lifespan=lifespan)

Now every request reuses the same session, connection pool, DNS cache.

TCPConnector parameters

The TCPConnector manages the connection pool:

Parameter What
limit total concurrent connections (default 100)
limit_per_host per-(scheme, host, port) cap (default 0 = no limit)
enable_cleanup_closed clean up SSL connections that didn’t close cleanly
ttl_dns_cache DNS cache TTL in seconds (default 10)
force_close close after each request (defeats the point of keepalive)
keepalive_timeout how long an idle connection stays in the pool (default 15s)
ssl custom SSL context

Tuning for high concurrency:

connector = aiohttp.TCPConnector(
    limit=200,
    limit_per_host=50,
    ttl_dns_cache=300,
    enable_cleanup_closed=True,
)

For a service calling 5 different downstreams, limit_per_host=50 × 5 hosts = 250 max connections. Set limit higher than that or it’ll be the bottleneck.

Connection pool behavior

When a request finishes:

  1. Response read fully → connection returned to the pool, available for reuse.
  2. Response not fully read (early return, exception) → connection may be closed (depends on protocol state).
  3. Idle in pool > keepalive_timeout → closed.

Critical: always read or close the response. Otherwise the connection stays in a half-open state and may not return to the pool.

# WRONG — connection may not be returned
async with session.get(url) as resp:
    if resp.status == 200:
        return await resp.text()
    return None     # didn't read body; connection state ambiguous

# RIGHT
async with session.get(url) as resp:
    if resp.status == 200:
        return await resp.text()
    await resp.read()    # drain body even if discarded
    return None

The async with block does call release() on exit, which drains the body — so the simple async with form is usually fine. Manual response handling without async with requires explicit release() or close().

DNS caching

Default DNS cache is 10 seconds. For a service calling stable hosts, raise to 5+ minutes:

connector = aiohttp.TCPConnector(ttl_dns_cache=300, use_dns_cache=True)

Without DNS caching, every connection does a fresh resolution → unnecessary load and latency.

For services behind cloud load balancers (ALB, GCLB) where IPs rotate, keep TTL short so connections rotate too.

Custom resolvers

For DNS-over-HTTPS or DNS round-robin behavior:

import aiodns

resolver = aiohttp.AsyncResolver(nameservers=["1.1.1.1", "1.0.0.1"])
connector = aiohttp.TCPConnector(resolver=resolver)

Default uses the OS resolver. Custom is rarely needed.

Multi-session apps

If you need different connection-pool sizes / timeouts per downstream, create multiple sessions:

class ApiClient:
    def __init__(self):
        self.stripe = aiohttp.ClientSession(
            timeout=ClientTimeout(total=10, sock_read=5),
            connector=TCPConnector(limit_per_host=20),
        )
        self.internal = aiohttp.ClientSession(
            timeout=ClientTimeout(total=2, sock_read=1),
            connector=TCPConnector(limit_per_host=100),
        )

One session per logical downstream. Avoids one slow downstream starving another’s connection budget.

Cookies and auth

session = aiohttp.ClientSession(
    cookies={"session": "abc"},
    auth=aiohttp.BasicAuth("user", "pass"),
    headers={"User-Agent": "MyApp/1.0"},
)

Set at the session level → applied to every request. Override per-request as needed.

For session-style API access (cookie auth across requests):

async with session.post("/login", json={...}) as resp:
    ...    # cookie stored in session's jar

async with session.get("/protected") as resp:
    ...    # cookie automatically sent

Common bugs

  • ClientSession per request. Re-creates pool, re-resolves DNS, no keepalive. Use one per app.
  • Forgetting to call session.close(). Resources leak. Wire to FastAPI/Starlette lifespan.
  • Not draining response body. Connection state ambiguous; may not return to pool. Use async with resp: consistently.
  • Default limit_per_host=0 — unlimited connections per host. A bug downstream causing slow responses → unbounded fd usage. Always set a cap.
  • Default keepalive_timeout=15s can hold connections open longer than your upstream LB’s idle timeout. Mismatch → “Connection reset by peer” on first reuse. Align timeouts (your timeout < LB idle timeout - buffer).

Compare to httpx

# httpx — sync or async, similar pool semantics
async with httpx.AsyncClient(timeout=10, limits=httpx.Limits(max_connections=100)) as client:
    r = await client.get(url)

httpx and aiohttp are both fine choices. Differences:

  • httpx supports sync and async with one API; aiohttp is async-only.
  • httpx is HTTP/2 capable; aiohttp is HTTP/1.1 only (HTTP/2 in 4.0 beta).
  • aiohttp also includes a server framework; httpx is client-only.
  • Performance is similar for client-side use; aiohttp tends slightly faster for raw throughput.

For Python backend roles, httpx is the more common choice for HTTP clients. aiohttp shows up in older codebases or where you want one library for both client + server.

Interview angle

  • “Why is creating aiohttp.ClientSession() per request bad?” — each session has its own connection pool, DNS cache, and SSL context. Per-request creation forfeits connection reuse, re-resolves DNS, opens new TLS connections each time. Use one session per application.
  • “How do you configure connection limits?”TCPConnector(limit=N, limit_per_host=M). limit caps total connections; limit_per_host caps per-host. Set both to avoid one downstream starving others.
  • “What’s the keepalive_timeout and how does it interact with load balancers?” — how long an idle pooled connection stays open. Default 15s. Must be shorter than your upstream LB’s idle timeout (typically 60s), or you’ll try to reuse a connection the LB has closed → “Connection reset” on first send.
  • “Why might a connection not return to the pool after a response?” — body wasn’t fully read or release() wasn’t called. Use async with response: consistently; it drains and releases on exit.
  • “aiohttp vs httpx for client work?” — both async, both have pool semantics. httpx supports HTTP/2 and sync+async with one API; aiohttp is HTTP/1.1 and async-only but slightly faster for raw throughput. For most projects: httpx.
  • “You see file descriptor exhaustion in production — typical cause?” — sessions per request not closed (or not properly drained) leak fds. Fix: one session per app + explicit close in lifespan; always async with the response.