Backend / Web frameworks / aiohttp / 01_clientsession_lifecycle.md

aiohttp — ClientSession Lifecycle and Connection Pooling

Updated 6 interview angles 5 min read source
On this page11
  1. The wrong pattern
  2. The right pattern
  3. TCPConnector parameters
  4. Connection pool behavior
  5. DNS caching
  6. Custom resolvers
  7. Multi-session apps
  8. Cookies and auth
  9. Common bugs
  10. Compare to httpx
  11. Interview angle

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

python
async def fetch(url):
    # NEW session per call
    async with aiohttp.ClientSession() as session:
        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:

python
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()
            # wait for SSL shutdown
            await asyncio.sleep(0.25)

http_client = HTTPClient()

In FastAPI, wire it through lifespan:

python
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:

python
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.

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

# 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:

python
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:

python
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:

python
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

python
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):

python
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

python
# 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 6

  • “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.