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.
@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.
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:
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.
# Built once at startup, reused.
@asynccontextmanager
async def lifespan(app: FastAPI):
async with aioboto3.Session().client("s3") as s3:
app.state.s3 = s3
yieldThe 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.
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.
Related
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_threadto move the call off the loop, which needs no new dependency but is bounded by the thread pool, oraioboto3for 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(). Andaiobotocorepins closely tobotocore, 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.