Skip to content

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

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.

Latency to a fast host while a slow host holds connections 2 horizontal bars comparing limit=20 only with the others. Latency to a fast host while a slow host holds connections limit=20 only 0.97 s limit=20, limit_per_host=10 0.01 s aiohttp 3.14; 20 requests to the slow host started first, then 10 to the fast host. A per-host limit keeps one slow dependency from taking the whole pool.

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.

aiohttp timeouts and what they cover A grid of 4 rows by 3 columns. aiohttp timeouts and what they cover field covers measured total pool wait + connect + transfer queued requests failed at 1.50 s connect pool wait + connection setup - sock_connect TCP + TLS handshake only - sock_read gap between received chunks - aiohttp's total is a deadline; httpx has no equivalent without asyncio.timeout.

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.

How should this connector be configured? A decision on What does the session talk to with 3 outcomes. How should this connector be configured? What does the session talk to? many hosts (crawler) big limit, small per-host polite, bounded fds a few backends (gateway) per-host as bulkhead slow one cannot starve one dependency limit = per-host = capacity sized by Little's law limit_per_host is what turns a pool into isolation.

Verification

A connector is configured well when:

  • Both limit and limit_per_host are set, chosen by the session's role.
  • total timeouts act as deadlines, with connect flagging 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 connect and sock_connect. Only connect includes 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.