Configuring aiohttp TCPConnector Limits¶
aiohttp.TCPConnector is aiohttp's connection pool. Two numbers control it: limit, the total connections across all hosts (default 100), and limit_per_host (default 0, meaning no per-host limit). With only the total limit, one slow host can occupy every connection and starve requests to healthy hosts. Tested with aiohttp 3.14 and limit=20: twenty requests to a host answering in 1.0 s filled the pool, and requests to a second host answering in 10 ms waited 0.97 s for a connection. Adding limit_per_host=10 brought them back to 0.01 s. Unlike httpx, aiohttp's total timeout includes the wait for a connection: with limit=2 and ClientTimeout(total=1.5), four 1-second requests produced two successes and two timeouts at exactly 1.50 s. This guide sets the connector for crawlers, gateways and service clients.
Prerequisites¶
- Python 3.11+,
pip install aiohttp; measured with aiohttp 3.14. - Session reuse, from reusing aiohttp ClientSession across requests.
- Bulkheads, from Circuit Breakers & Bulkheads.
1. Set a total and a per-host limit¶
Create the connector explicitly and pass it to the session, so its limits are visible in code rather than inherited:
import aiohttp
connector = aiohttp.TCPConnector(
limit=100, # total connections across all hosts (default 100; 0 = unlimited)
limit_per_host=10, # per (host, port, ssl) key (default 0 = unlimited)
keepalive_timeout=15, # seconds an idle connection is kept (default 15)
ttl_dns_cache=300, # seconds DNS answers are cached (default 10)
)
session = aiohttp.ClientSession(connector=connector)
The per-host key is the host name, port and TLS setting — 127.0.0.1 and localhost count as different hosts even when they are the same machine, which is how the test above put a slow and a fast "host" on one server. limit=0 removes the total cap entirely; avoid it except in tightly controlled tools, because a burst then opens as many sockets as there are requests.
Verify: under load, the connections per remote host (ss -tn | awk '{print $5}' | sort | uniq -c) never exceed limit_per_host.
2. Choose limits by role¶
The right numbers depend on what the session is for:
# Crawler: many hosts, be polite to each, cap total sockets
crawler = aiohttp.TCPConnector(limit=500, limit_per_host=4, ttl_dns_cache=600)
# API gateway fanning out to a few backends: protect each backend
gateway = aiohttp.TCPConnector(limit=300, limit_per_host=100)
# Client for a single dependency: limit_per_host is the limit
payments = aiohttp.TCPConnector(limit=50, limit_per_host=50)
For a crawler, limit_per_host is a politeness and anti-blocking measure, and limit caps file descriptors and memory — the per-host strategies are in limiting concurrency per host in an async crawler. For a gateway, limit_per_host is a bulkhead: a slow backend uses at most its share. For a single dependency the two are the same number, chosen from the dependency's capacity and Little's law as in sizing async connection pools for throughput.
Several sessions can share one connector by passing connector_owner=False, which pools connections across them; more often you want the opposite — one session and connector per dependency — so that each has its own limits and its own failure domain. The connector belongs to the session that created it by default and is closed with it, so close sessions in your application's shutdown path rather than leaving it to garbage collection, which logs "Unclosed connector" warnings.
Verify: a slow backend in a load test raises latency only for requests to that backend.
3. Rely on total timeout including the queue¶
In aiohttp, ClientTimeout(total=...) covers the whole request: waiting for a connector slot, connecting, sending and reading. That makes it a true deadline:
timeout = aiohttp.ClientTimeout(
total=5.0, # everything, including waiting for a pooled connection
connect=1.0, # acquiring a connection: pool wait + connection setup
sock_connect=0.5, # TCP + TLS handshake only
sock_read=2.0, # gap between received chunks
)
session = aiohttp.ClientSession(connector=connector, timeout=timeout)
async with session.get(url, timeout=aiohttp.ClientTimeout(total=1.5)) as r: # per-request override
body = await r.read()
Tested with limit=2 and four concurrent requests to a 1.0 s endpoint with total=1.5: the two that got connections succeeded at 1.0 s; the two that queued timed out at 1.50 s, because their queueing counted. Note the naming trap: connect includes the pool wait, while sock_connect is only the handshake. Set connect when you want a separate bound on "could not get a connection quickly", which is the signal that the pool is too small or a dependency is stuck.
Verify: with the pool saturated, no request exceeds total, and the errors are TimeoutError.
4. Tune DNS caching and connection reuse¶
The connector also caches DNS answers and keeps idle connections. Both defaults are conservative:
connector = aiohttp.TCPConnector(
limit=100,
limit_per_host=20,
ttl_dns_cache=300, # default 10 s re-resolves often for long-lived sessions
use_dns_cache=True,
keepalive_timeout=30, # keep idle connections longer than the 15 s default...
enable_cleanup_closed=True, # ...and clean up SSL transports the peer closed
)
A longer keepalive_timeout saves handshakes for traffic with gaps longer than 15 s, but must stay below the server's or load balancer's idle timeout, or the client will send requests on connections the other side has closed — see handling stale pooled connections after idle timeouts. A longer ttl_dns_cache reduces resolver load but delays noticing a DNS change, such as a failover; balance it against how quickly targets move. DNS costs in detail are in caching DNS lookups in async HTTP clients.
Verify: connection setup counts drop after raising keepalive_timeout, with no rise in connection-reset errors.
5. Observe the connector¶
aiohttp's tracing API reports when a request waits for a connection slot and when it gets one, which gives you a pool-wait metric:
import time
trace = aiohttp.TraceConfig()
async def on_queued_start(session, ctx, params):
ctx.queued_at = time.perf_counter()
async def on_queued_end(session, ctx, params):
POOL_WAIT.observe(time.perf_counter() - ctx.queued_at)
async def on_create_start(session, ctx, params):
NEW_CONNECTIONS.inc()
trace.on_connection_queued_start.append(on_queued_start)
trace.on_connection_queued_end.append(on_queued_end)
trace.on_connection_create_start.append(on_create_start)
session = aiohttp.ClientSession(connector=connector, trace_configs=[trace])
on_connection_queued_* fires only when a request had to wait because the limit was reached, so any samples at all mean the pool was saturated at that moment. A high rate of on_connection_create_start relative to requests means connections are not being reused — usually a session created per request, or a keepalive timeout too short for the traffic pattern.
Verify: the pool-wait metric is empty at normal load and appears during a load test that exceeds the limits.
Verification¶
A connector is configured well when:
- Both
limitandlimit_per_hostare set, chosen by the session's role. totaltimeouts act as deadlines, withconnectflagging pool waits.- Keepalive is below the server's idle timeout, and DNS caching fits how often targets move.
- Pool waits and new connections are measured with trace hooks.
Diagnostic Hook: chart pool-wait samples per target host. Waits concentrated on one host mean its limit_per_host is saturated — the host is slow or under-provisioned. Waits spread across all hosts mean the total limit is the constraint.
Pitfalls & edge cases¶
- No per-host limit. A slow host delayed a fast one from 0.01 s to 0.97 s.
limit=0. Unbounded sockets under bursts.- Confusing
connectandsock_connect. Onlyconnectincludes the pool wait. - A connector per request. Every request pays a new handshake and nothing is reused.
- Sessions left for the garbage collector. Close them at shutdown to release connections cleanly.
Frequently Asked Questions¶
What is limit_per_host in aiohttp?
The maximum number of simultaneous connections to one host, port and TLS combination. The default 0 means unlimited, so only the total limit applies and one slow host can occupy every connection.
What are the default aiohttp connection limits?
TCPConnector defaults to limit=100 total connections, limit_per_host=0 (no per-host limit), a 15 second keepalive_timeout and a 10 second DNS cache.
Does aiohttp's total timeout include waiting for a connection?
Yes. In testing, requests queued behind a full pool timed out at exactly the total of 1.5 s. The connect timeout also includes the pool wait; sock_connect does not.
How do I monitor aiohttp connection pool saturation?
Add a TraceConfig with on_connection_queued_start and on_connection_queued_end hooks; they fire only when a request waits for a free connection.
Related¶
- Connection Pooling & Keep-Alive — up to the topic overview.
- Configuring httpx limits and pool timeouts — the same decisions for httpx.
- Network I/O & Protocol Handling — the section overview.