Running asyncio in AWS Lambda Handlers¶
AWS Lambda's Python runtime calls a synchronous handler function, once per invocation, in an execution environment that is reused for later invocations until Lambda retires it. An async codebase has to bridge that: something must run the event loop inside each call. The obvious bridge — asyncio.run() in the handler — works, and also creates and destroys an event loop on every invocation, which throws away anything bound to it. Measured with the AWS Lambda Python 3.13 base image and its Runtime Interface Emulator, calling an nginx upstream over a Docker network: a handler that ran asyncio.run() with a new aiohttp.ClientSession per invocation took 0.87 ms at the median for warm invocations; a handler that kept one module-level event loop and one session, and called loop.run_until_complete() per invocation, took 0.19 ms. A handler that created a global session inside its first asyncio.run() and reused it in the next succeeded once and then failed 20 of 20 warm invocations with RuntimeError: Event loop is closed. Cold starts — the first invocation — took 160–214 ms end to end. This guide structures async Lambda handlers so warm invocations reuse what they can.
Prerequisites¶
- Python 3.11+, an async client library (aiohttp, httpx, aioboto3).
- Event loop reuse, from reusing one loop across calls with asyncio.Runner.
- The topic overview, Containers & Serverless.
1. Start with asyncio.run per invocation¶
The simplest correct handler runs a coroutine and creates every async resource inside it:
import asyncio
import aiohttp
UPSTREAM = "https://api.example.com/items"
async def _work(event: dict) -> dict:
async with aiohttp.ClientSession() as session: # created and closed in this loop
async with session.get(UPSTREAM, params={"id": event["id"]}) as resp:
return {"status": resp.status, "item": await resp.json()}
def handler(event, context):
return asyncio.run(_work(event))
Measured for warm invocations against a local upstream: 0.87 ms per invocation at the median. Everything is correct because nothing outlives the loop that created it. The cost is that nothing is reused: every invocation creates a loop, a session and a TCP connection — and with TLS to a real API, a handshake, which costs about a millisecond of CPU plus a network round trip, as measured in measuring TLS handshake cost in async services. For low-traffic functions that is fine.
Verify: the handler creates no module-level async objects, so there is nothing to go stale between invocations.
2. Do not keep loop-bound objects across asyncio.run¶
The tempting optimization keeps the session in a global and creates it lazily:
SESSION = None # module level: survives between invocations
async def _work(event):
global SESSION
if SESSION is None:
SESSION = aiohttp.ClientSession() # bound to the loop of the first asyncio.run
async with SESSION.get(UPSTREAM) as resp:
return resp.status
def handler(event, context):
return asyncio.run(_work(event)) # second call: a new loop, an old session
Measured: the first invocation succeeded; all 20 warm invocations after it failed with RuntimeError: Event loop is closed, because the session's connector and its pooled connections belong to the loop that asyncio.run closed when the first invocation ended. The same applies to database pools (asyncpg, aioredis), aioboto3 clients and anything that holds futures or transports. The error is deterministic, which is the good news: it fails on the second invocation, not intermittently.
Verify: a local test invokes the handler at least twice in one process; a single-invocation test cannot catch this.
3. Keep one loop for the execution environment¶
To reuse connections, keep the event loop itself at module level, and run each invocation on it:
import asyncio
import aiohttp
LOOP = asyncio.new_event_loop()
asyncio.set_event_loop(LOOP)
SESSION: aiohttp.ClientSession | None = None
async def _session() -> aiohttp.ClientSession:
global SESSION
if SESSION is None:
SESSION = aiohttp.ClientSession(connector=aiohttp.TCPConnector(keepalive_timeout=60))
return SESSION
async def _work(event: dict) -> dict:
session = await _session()
async with session.get(UPSTREAM, params={"id": event["id"]}) as resp:
return {"status": resp.status, "item": await resp.json()}
def handler(event, context):
return LOOP.run_until_complete(_work(event))
Measured: 0.19 ms per warm invocation, against 0.87 ms for asyncio.run per call — the pooled connection to the upstream was reused instead of reopened. The loop is idle between invocations (the environment is frozen), so it is safe to keep. asyncio.Runner offers the same pattern with explicit lifecycle methods if you prefer it to a bare loop. What happens to those pooled connections while the environment is frozen is the subject of reusing connections across Lambda invocations.
Verify: warm invocations show no new TCP connections to the upstream (connection counts on the upstream, or client-side tracing).
4. Keep background tasks inside the invocation¶
Between invocations the environment is frozen: no code runs, including tasks you started with create_task and did not await. A task that is still pending when the handler returns may resume minutes later, or never:
def handler(event, context):
return LOOP.run_until_complete(_work(event))
async def _work(event: dict) -> dict:
result = await process(event)
# Wrong: fire-and-forget telemetry may never be sent
# asyncio.create_task(send_metrics(result))
# Right: finish it within the invocation, with a bound
try:
async with asyncio.timeout(0.5):
await send_metrics(result)
except TimeoutError:
log.warning("metrics not sent within budget")
return result
Lambda's model is that the work of an invocation completes before the handler returns; anything left over is paused with the environment and runs only if and when the next invocation thaws it. Flush telemetry, commit writes and close per-invocation resources before returning, within a bounded time, as covered for long-running services in flushing telemetry and logs before exit.
Verify: after each invocation, asyncio.all_tasks(LOOP) is empty apart from tasks you deliberately keep (such as a session's internal ones).
5. Keep cold starts small¶
The first invocation in a new environment pays for importing modules and creating clients. Measured end to end through the emulator, cold first invocations took 160–214 ms with aiohttp as the only dependency; import time grows with every library:
# Import heavy optional dependencies inside the code path that needs them
def handler(event, context):
if event.get("action") == "export":
from app.export import render_xlsx # only cold-loaded when needed
return LOOP.run_until_complete(render_xlsx(event))
return LOOP.run_until_complete(_work(event))
Import costs measured elsewhere on this site — about 26 ms for asyncio, 81 ms for httpx, 95 ms for aiohttp — add up quickly, as shown in writing async CLI commands with click and Typer. Creating the loop and clients at import time moves their cost into Lambda's init phase, which runs once per environment; creating connections lazily, on first use, avoids paying for connections an invocation may not need.
Verify: python -X importtime -c "import app" in the function's image shows no large imports that most invocations do not use.
Verification¶
An async Lambda handler is structured well when:
- Each invocation runs on a loop that owns every async resource it uses — per-call
asyncio.run, or one module-level loop for everything. - No loop-bound object survives across
asyncio.runcalls. - Every task started during an invocation finishes before the handler returns.
- Heavy imports are deferred and the cold start is measured.
Diagnostic Hook: invoke the handler twice in a row in your local tests and in a staging deploy. RuntimeError: Event loop is closed on the second invocation is the loop-binding mistake; a second invocation that is not faster than the first means nothing is being reused.
Pitfalls & edge cases¶
- Global sessions with
asyncio.run. Measured: 20 of 20 warm invocations failed. - Fire-and-forget tasks. They freeze with the environment and may never run.
- Testing a single invocation. The loop-binding bug appears on the second.
- Heavy top-level imports. Every cold start pays for them.
Frequently Asked Questions¶
How do I use asyncio in an AWS Lambda handler?
Lambda calls a synchronous handler, so run your coroutine inside it: either asyncio.run(main(event)) with all async resources created inside, or a module-level event loop with loop.run_until_complete(...) to reuse sessions. Warm invocations took 0.87 ms and 0.19 ms respectively in testing.
Why do I get 'Event loop is closed' in Lambda?
A module-level client or session was created inside one asyncio.run call and reused in the next; asyncio.run closed the loop it belonged to. In testing, all 20 warm invocations failed this way. Keep one module-level loop or create clients per invocation.
Can I reuse an aiohttp session across Lambda invocations?
Yes, if the event loop is reused too: keep a module-level loop and run each invocation with loop.run_until_complete. The session's pooled connections then survive between invocations.
Do background asyncio tasks run after a Lambda handler returns?
Not reliably: the execution environment is frozen between invocations, so pending tasks pause and run only when, and if, a later invocation thaws it. Await everything within the invocation.
Related¶
- Containers & Serverless — up to the topic overview.
- Reusing connections across Lambda invocations — what happens to pooled connections while frozen.
- Resilience, Cancellation & Error Handling — the section overview.