Skip to content

Managing aioboto3 Clients Without Leaking Connections

An aioboto3 client is not a lightweight handle. It owns an aiohttp connector with a connection pool, resolved credentials, and the service's endpoint and model data, and it must be entered and exited as an async context manager. Getting its lifecycle wrong shows up as latency, hangs or warnings rather than errors. Tested with aioboto3 15.5 against a local S3-compatible server: creating and closing a client for each request cost 25.9 ms per get_object, against 2.1 ms with one shared client. The default max_pool_connections of 10 allowed about 10 response bodies open at once (peak 11), and 50 concurrent 4 MiB downloads took 0.78 s; with a pool of 50 they took 0.58 s with 50 open. With a pool of 2, two response bodies left unread made a third request wait until its 3.0 s timeout; closing them let it finish in 35 ms. A client never exited produced aiohttp's "Unclosed connector" warning. This guide sets up the lifecycle and the pool correctly.

Prerequisites

1. Create one client per process, in the lifespan

A Session is cheap and holds configuration; a client is expensive and holds connections. Enter the client once at startup and keep it for the life of the process:

import aioboto3
from aiobotocore.config import AioConfig

session = aioboto3.Session()                     # configuration only


@asynccontextmanager
async def lifespan(app):
    config = AioConfig(max_pool_connections=50, connect_timeout=5, read_timeout=30,
                       retries={"mode": "adaptive", "max_attempts": 5})
    async with session.client("s3", config=config) as s3:
        app.state.s3 = s3                        # shared by every request
        yield                                    # exiting closes the pool cleanly


async def get_report(request):
    s3 = request.app.state.s3
    ...

Measured: 25.9 ms per operation with a client per request versus 2.1 ms shared — client creation, credential resolution and a fresh TCP (and in production TLS) connection, every time. A client created in a request handler and not exited also leaks its connector: tested, dropping a client without exiting it produced aiohttp's "Unclosed connector" warning listing the open connection. In worker-process servers, each worker creates its own client in its own lifespan.

Verify: the number of clients created over a load test equals the number of worker processes.

get_object latency by client lifecycle 2 horizontal bars comparing client per request with the others. get_object latency by client lifecycle client per request 25.9 ms/op one shared client 2.1 ms/op aioboto3 15.5, local S3-compatible server, 100 sequential gets of 10 KB objects. Creating clients is the expensive part; reuse them.

2. Size max_pool_connections to your concurrency

The pool caps simultaneous requests. Requests beyond it wait inside the client for a connection — silently, and counted against their timeouts:

config = AioConfig(max_pool_connections=64)          # default is 10

slots = asyncio.Semaphore(64)                         # same number at the application level


async def fetch(s3, bucket: str, key: str) -> bytes:
    async with slots:                                 # wait here, visibly, not inside the pool
        response = await s3.get_object(Bucket=bucket, Key=key)
        async with response["Body"] as body:
            return await body.read()

Measured with 50 concurrent 4 MiB downloads read by a slow consumer: the default pool kept about 10 bodies open at once (peak 11) and took 0.78 s; a pool of 50 kept 50 open and took 0.58 s. The gap grows with latency, since each waiting request sits idle for a full round trip. A semaphore with the same size makes the waiting explicit and measurable, and keeps queued requests from consuming their timeout budget inside the pool.

Verify: under load, requests in flight approach the pool size, and time spent waiting on the semaphore is exported as a metric.

3. Read or close every response body

A response body holds its connection until it has been read to the end or closed. Bodies that are dropped, partially read, or stored for later keep connections out of the pool:

async def read_small_leaky(s3, bucket: str, key: str) -> bytes | None:
    response = await s3.get_object(Bucket=bucket, Key=key)
    if response["ContentLength"] > LIMIT:
        return None                                   # leak: body never read or closed
    return await response["Body"].read()


async def read_small(s3, bucket: str, key: str) -> bytes | None:
    response = await s3.get_object(Bucket=bucket, Key=key)
    async with response["Body"] as body:              # releases the connection on every path
        if response["ContentLength"] > LIMIT:
            return None
        return await body.read()

Tested with max_pool_connections=2: after two get_object calls whose 4 MiB bodies were neither read nor closed, a third call waited until its 3.0 s timeout; after closing the two bodies, the same call completed in 35 ms. Small bodies hide the problem, because aiohttp often reads them completely into its buffer and frees the connection early — tests with small objects pass while production with large ones hangs. Use head_object when you only need metadata.

Verify: a test with a pool of 2 performs many get-and-skip operations in a row without stalling.

Effect of unread bodies on a small pool A grid of 2 rows by 2 columns. Effect of unread bodies on a small pool state of the pool third get_object 2 bodies unread, not closed TimeoutError after 3.0 s same 2 bodies closed completed in 0.035 s aioboto3 15.5, 4 MiB objects; small objects may be fully buffered and hide the leak.

4. Use paginators and resources inside the client's lifetime

Paginators and streaming bodies borrow the client's connections, so they must be consumed before the client exits:

async def list_keys(s3, bucket: str, prefix: str) -> list[str]:
    keys = []
    paginator = s3.get_paginator("list_objects_v2")
    async for page in paginator.paginate(Bucket=bucket, Prefix=prefix):
        keys.extend(obj["Key"] for obj in page.get("Contents", []))
    return keys


# Resources (the higher-level API) are context managers too
async with session.resource("s3") as s3r:
    bucket = await s3r.Bucket("reports")
    async for obj in bucket.objects.filter(Prefix="2026/"):
        print(obj.key)

Returning a paginator, an unread body or a resource object out of an async with block and using it after the client has closed fails with errors about a closed session. Background tasks that use the shared client must be finished or cancelled before the lifespan exits; order the shutdown so they stop first, as in Graceful Shutdown & Signals.

Verify: shutting down the application during active transfers produces no "Unclosed connector" or "session is closed" errors.

5. Use separate clients for separate failure domains

One client per process is the default; more than one is right when workloads need different settings or must not starve each other:

@asynccontextmanager
async def lifespan(app):
    async with AsyncExitStack() as stack:
        app.state.s3_api = await stack.enter_async_context(
            session.client("s3", config=AioConfig(max_pool_connections=32, read_timeout=5)))
        app.state.s3_batch = await stack.enter_async_context(
            session.client("s3", config=AioConfig(max_pool_connections=16, read_timeout=120)))
        app.state.s3_eu = await stack.enter_async_context(
            session.client("s3", region_name="eu-west-1"))
        yield

A separate client for batch transfers keeps a large export from occupying the connections that user-facing requests need — a bulkhead, as in bulkhead isolation with per-dependency semaphores. Clients are per region and per service, so a multi-region application holds one per region. AsyncExitStack closes them all in reverse order at shutdown.

Verify: a long batch transfer running alongside a load test does not raise the user-facing requests' pool wait.

How many aioboto3 clients does this process need? A decision on What does the process do with storage with 3 outcomes. How many aioboto3 clients does this process need? What does the process do with storage? one kind of work one client pool = concurrency interactive + batch a client each bulkhead several regions/services one per region/service AsyncExitStack Clients are long-lived; create them deliberately and close them at shutdown.

Verification

aioboto3 clients are managed well when:

  • Clients are created once per process in the lifespan and exited at shutdown.
  • max_pool_connections matches intended concurrency, with a semaphore of the same size.
  • Every response body is read or closed, using its context manager.
  • Separate workloads get separate clients where they must not starve each other.

Diagnostic Hook: export the number of open connections per client and the time spent waiting for the application semaphore. Open connections pinned at the pool size with idle CPU means the pool or unread bodies are the limit; "Unclosed connector" warnings in logs mean a client path that is never exited.

Pitfalls & edge cases

  • A client per request. Measured: 25.9 ms of overhead per operation.
  • The default pool of 10. Concurrency silently capped around 10.
  • Unread bodies. Tested: a pool of 2 stalled until timeout.
  • Using a client's objects after it exits. Paginators and bodies fail with closed-session errors.

Frequently Asked Questions

Should I create a new aioboto3 client for each request?

No. Create one client per process at startup and share it. In testing, a client per request cost 25.9 ms per operation against 2.1 ms with a shared client.

What is the default connection pool size in aioboto3?

max_pool_connections defaults to 10, which caps concurrent requests at about 10. Raise it with AioConfig(max_pool_connections=...) to match your concurrency.

Why do my aioboto3 requests hang?

Often all pooled connections are held by response bodies that were never read or closed. Use async with response["Body"] as body so the connection is released on every path.

How do I fix 'Unclosed connector' warnings from aioboto3?

Enter clients with async with (or an AsyncExitStack) and exit them at shutdown, and do not create clients in request handlers.