Skip to content

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

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.

Three handler styles, warm invocations A grid of 3 rows by 4 columns. Three handler styles, warm invocations handler style cold invoke (end to end) warm handler time warm failures asyncio.run + new session each time 214 ms 0.87 ms median 0 of 20 module-level loop + reused session 160 ms 0.19 ms median 0 of 20 global session reused across asyncio.run 167 ms n/a 20 of 20: Event loop is closed AWS Lambda Python 3.13 base image with its Runtime Interface Emulator; upstream on a Docker network.

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).

One execution environment, many invocations A flow of 5 stages. One execution environment, many invocations init phase import, new_event_loop() invoke 1 run_until_complete: session created frozen loop idle, sockets open invoke 2..N reuse session: 0.19 ms retired no shutdown hook guaranteed Async resources live as long as the loop that owns them.

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.

How should this Lambda run its async code? A decision on What matters most for this function with 4 outcomes. How should this Lambda run its async code? What matters most for this function? simplicity, low traffic asyncio.run per call 0.87 ms warm warm latency, repeated upstreams module-level loop + session 0.19 ms warm already has global async clients must use the module-level loop else Event loop is closed telemetry and writes await within the invocation frozen afterwards The loop's lifetime decides what can be reused.

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.run calls.
  • 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.