Backend / Async & concurrency / 17_async_aws_aioboto3.md

Async AWS calls: aioboto3 and the alternatives

Updated 5 interview angles 4 min read source
On this page8
  1. Why it matters more than it looks
  2. Option 1: run it in a thread
  3. Option 2: aioboto
  4. Option 3: skip the SDK
  5. Which to choose
  6. What you inherit either way
  7. Related
  8. Interview angle

Async AWS calls: aioboto3 and the alternatives

boto3 is synchronous. Calling it from an async handler blocks the event loop for the whole round trip, which in an async service is the bug that quietly halves your throughput.

Why it matters more than it looks

One blocking call does not block one request - it blocks every coroutine on that worker, because there is a single event loop.

python
@app.get("/report")
async def report():
    # 200ms of S3 latency during which the loop
    # serves nobody.
    obj = s3.get_object(Bucket=b, Key=k)
    return parse(obj["Body"].read())

Under load this looks like latency that grows with concurrency while CPU sits idle, and it is easy to misdiagnose as a slow dependency.

Option 1: run it in a thread

The cheapest fix, and often the right one. asyncio.to_thread moves the blocking call off the loop.

python
obj = await asyncio.to_thread(
    s3.get_object, Bucket=b, Key=k
)

Correct, uses the SDK everyone knows, and needs no new dependency. The ceiling is the thread pool: each in-flight call holds a thread, so this scales to tens of concurrent calls, not thousands.

For occasional AWS calls in a mostly-async service, stop here.

Option 2: aioboto3

aioboto3 wraps aiobotocore, which reimplements botocore’s HTTP layer on aiohttp. Same API shape, native coroutines, no threads.

The difference from boto3 is that clients are async context managers, not plain objects:

python
session = aioboto3.Session()
async with session.client("s3") as s3:
    obj = await s3.get_object(Bucket=b, Key=k)
    body = await obj["Body"].read()

Note Body.read() is also awaited - the streaming body is async too, which is the line people miss when porting.

Do not create a client per request

This is the mistake that makes aioboto3 slower than the thread pool it replaced. Client construction loads service JSON models and builds a signer; it is expensive, and doing it per request dominates the call.

python
# Built once at startup, reused.
@asynccontextmanager
async def lifespan(app: FastAPI):
    async with aioboto3.Session().client("s3") as s3:
        app.state.s3 = s3
        yield

The context manager must stay open for the app’s lifetime, which is exactly what a lifespan handler is for. Creating it inside a dependency and letting it close per request throws away the connection pool.

Option 3: skip the SDK

For plain S3 GETs and PUTs, a presigned URL generated with sync boto3 at startup and fetched with httpx is less machinery than either option. The signing is local and cheap; only the transfer needs to be async.

Which to choose

Use it when
to_thread A few AWS calls; no new dependency
aioboto3 High concurrency; AWS is a hot path
Presigned + httpx Bulk S3 transfer, nothing else

The deciding question is how many concurrent AWS calls a single worker makes. Below the thread pool size, threads win on simplicity. Above it, the thread pool becomes the bottleneck and native async is the answer.

What you inherit either way

aiobotocore tracks botocore versions closely, and that pin is real operational friction: it constrains which boto3 you can hold in the same environment, and lags new AWS features by a release or two.

Retries and timeouts still come from botocore’s config, not from your HTTP client, and the defaults are generous.

python
cfg = Config(
    retries={"max_attempts": 3, "mode": "standard"},
    connect_timeout=3,
    read_timeout=10,
)

Without an explicit read_timeout, a hung AWS call in an async service is a coroutine that never completes and never frees its slot - the async version of a leak.

Interview angle 5

  • “What happens if you call boto3 from an async handler?” - the synchronous HTTP call blocks the event loop, so no other coroutine on that worker runs for its duration. It shows up as latency rising with concurrency while CPU stays idle.
  • “How do you fix it?” - either asyncio.to_thread to move the call off the loop, which needs no new dependency but is bounded by the thread pool, or aioboto3 for native coroutines when AWS is a hot path with high concurrency.
  • “What is the trap with aioboto3?” - clients are async context managers and are expensive to construct, because building one loads service models and a signer. Creating one per request costs more than the blocking call did; build it once in a lifespan handler and reuse it.
  • “Anything else that differs from boto3?” - the streaming response body is async too, so await obj["Body"].read(). And aiobotocore pins closely to botocore, which constrains versions in the same environment and lags new AWS features slightly.
  • “When would you use neither?” - for bulk S3 transfer, sign a URL locally with sync boto3 and move the bytes with an async HTTP client. Signing is cheap and local; only the transfer benefits from async.