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:
- Response read fully → connection returned to the pool, available for reuse.
- Response not fully read (early return, exception) → connection may be closed (depends on protocol state).
- 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=15scan 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).limitcaps total connections;limit_per_hostcaps per-host. Set both to avoid one downstream starving others. - “What’s the
keepalive_timeoutand 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. Useasync 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 withthe response.