Category: Cloud & Infrastructure Tags: aws aws-lambda concurrency

Threads, Async, Await, and Event Loops in AWS Lambda

5.0 out of 5 from 1 votes
By Oleksandr Andrushchenko — Published on — Modified on
1 0
Threads, Async, Await, and Event Loops in AWS Lambda
Threads, Async, Await, and Event Loops in AWS Lambda

AWS Lambda already provides horizontal concurrency by running more execution environments as request volume grows. But a single invocation can still spend most of its lifetime waiting for HTTP APIs, S3, DynamoDB, Redis, databases, or other network services.

Threads and async IO can reduce that waiting time by allowing independent IO operations to overlap. The important distinction is that these techniques improve concurrency inside one Lambda invocation; they do not replace Lambda's own scaling model.

Table of Contents

AWS Lambda Execution Model

A Lambda function runs inside an execution environment managed by AWS. For Python, the runtime imports the function code, initializes module-level state, and invokes the configured handler when an event arrives.

def lambda_handler(event, context):
    return {
        "statusCode": 200,
        "body": "Hello from Lambda",
    }

An execution environment can be reused for later invocations, but application code must not assume that reuse will happen.

The complete initialization, invocation, freeze, reuse, and shutdown lifecycle is covered in AWS Lambda Execution Lifecycle Explained.

Lambda Concurrency vs Python Concurrency

Lambda concurrency and Python concurrency are different layers.

If four independent events are processed concurrently, Lambda can run multiple execution environments:

Request 1 → Lambda environment 1
Request 2 → Lambda environment 2
Request 3 → Lambda environment 3
Request 4 → Lambda environment 4

Threads and async operate at a different level:

One Lambda invocation
        │
        ├── HTTP request A
        ├── HTTP request B
        └── HTTP request C

They allow one invocation to overlap independent work instead of waiting for each operation sequentially.

Lambda concurrency controls, scaling behavior, reserved concurrency, and downstream pressure are covered in AWS Lambda Concurrency and Scaling.

Cold Starts and Warm Starts

A cold start creates and initializes a new execution environment. A later invocation may reuse that environment as a warm environment.

Cold start:

Create environment
      ↓
Initialize runtime
      ↓
Import application
      ↓
Initialize global objects
      ↓
Invoke handler


Warm invocation:

Reuse environment
      ↓
Invoke handler

Reusable objects such as AWS SDK clients and HTTP clients can often be initialized outside the handler when their libraries support safe reuse.

However, warm reuse is an optimization opportunity, not a correctness guarantee. The function must still work correctly when AWS creates a completely new environment.

Cold-start behavior and optimization strategies are covered separately in AWS Lambda Cold Starts Explained.

Memory and CPU Allocation

Lambda CPU capacity increases with the configured memory allocation. Increasing memory can therefore reduce execution time for CPU-intensive work and sometimes improve workloads that combine IO with meaningful local processing.

This creates an important distinction:

Waiting on network
        ↓
Threads / async may help


Using CPU continuously
        ↓
More compute may help

Concurrency should not be added automatically to every slow function. First identify whether the invocation is waiting or computing.

Concurrency Inside One Lambda Invocation

Suppose one API request needs three independent remote calls:

Get profile      300 ms
Get orders       350 ms
Get permissions  250 ms

Sequential execution takes approximately:

300 + 350 + 250
≈ 900 ms

If the calls are independent, concurrency can overlap their waiting periods:

Time ─────────────────────────────→

Profile      |──────── 300 ms ────────|
Orders       |────────── 350 ms ─────────|
Permissions  |────── 250 ms ──────|

Total ≈ slowest operation + overhead

This is where invocation-level concurrency can substantially reduce latency.

It can also reduce billed duration, although the actual cost effect depends on memory allocation, runtime duration, downstream behavior, and concurrency overhead.

IO-Bound vs CPU-Bound Work

IO-bound workloads spend much of their time waiting:

  • HTTP APIs;
  • S3 operations;
  • DynamoDB requests;
  • Redis commands;
  • SQL queries;
  • queue operations.

CPU-bound workloads spend most of their time executing instructions:

  • image transformations;
  • compression;
  • large JSON transformations;
  • cryptographic operations;
  • heavy calculations;
  • some machine-learning inference.
Workload Typical Approach
Few simple operations Synchronous code
Independent blocking IO Threads
Many async-compatible IO operations Async IO
CPU-heavy Python More CPU, optimized libraries, or another compute model
Mixed workload Measure each stage first

Python threads can overlap blocking IO because the interpreter is not continuously executing Python code while a thread waits for network IO. They should not be treated as a general solution for CPU parallelism.

Threads in AWS Lambda

Threads are a practical choice when the application uses synchronous blocking libraries.

Typical examples include:

  • boto3;
  • requests;
  • synchronous SQL drivers;
  • blocking Redis clients;
  • other synchronous SDKs.

When one worker thread waits for a remote service, another can perform another independent IO operation.

Lambda invocation
       │
ThreadPoolExecutor
       │
   ┌───┼───────────┐
   ↓   ↓           ↓
  S3  DynamoDB    HTTP API

ThreadPoolExecutor

ThreadPoolExecutor provides bounded worker concurrency without manually creating and coordinating individual threads.

from concurrent.futures import ThreadPoolExecutor

import requests


URLS = [
    "https://api.example.com/profile",
    "https://api.example.com/orders",
    "https://api.example.com/permissions",
]


def fetch_json(url):
    response = requests.get(url, timeout=3)
    response.raise_for_status()
    return response.json()


def lambda_handler(event, context):
    with ThreadPoolExecutor(max_workers=3) as executor:
        profile, orders, permissions = executor.map(
            fetch_json,
            URLS,
        )

    return {
        "profile": profile,
        "orders": orders,
        "permissions": permissions,
    }

The important property is not simply that threads exist. The three operations are independent, and the pool has an explicit concurrency bound.

Increasing max_workers indefinitely does not produce unlimited throughput. It can instead move the bottleneck into a database, API, connection pool, or AWS service.

Threads with boto3

boto3 exposes synchronous APIs, making threads useful when several independent SDK operations must happen within one invocation.

from concurrent.futures import ThreadPoolExecutor

import boto3


s3 = boto3.client("s3")


def read_object(key):
    response = s3.get_object(
        Bucket="reports-bucket",
        Key=key,
    )

    return response["Body"].read()


def lambda_handler(event, context):
    keys = [
        "reports/a.json",
        "reports/b.json",
        "reports/c.json",
    ]

    with ThreadPoolExecutor(max_workers=3) as executor:
        files = list(executor.map(read_object, keys))

    return {
        "objects_read": len(files),
    }

The same pattern can be useful for independent DynamoDB operations.

from concurrent.futures import ThreadPoolExecutor

import boto3


table = boto3.resource("dynamodb").Table("AppTable")


def get_item(key):
    response = table.get_item(Key=key)
    return response.get("Item")


def lambda_handler(event, context):
    keys = [
        {"pk": "USER#1", "sk": "META"},
        {"pk": "USER#1", "sk": "SETTINGS"},
        {"pk": "USER#1", "sk": "PERMISSIONS"},
    ]

    with ThreadPoolExecutor(max_workers=3) as executor:
        items = list(executor.map(get_item, keys))

    return {"items": items}

Concurrency does not bypass DynamoDB limits. Partition design, throttling, retry behavior, and service quotas still apply.

Thread Limitations

Threads introduce several costs:

  • memory for each thread;
  • scheduling overhead;
  • shared-state synchronization;
  • more simultaneous network connections;
  • more downstream concurrency;
  • more complex exception handling.

For CPU-heavy Python code, threads are also constrained by the execution behavior of standard CPython and should not be expected to provide general CPU parallelism.

Threads are therefore most useful when bounded blocking IO is the actual bottleneck.

Async and Await in AWS Lambda

Async IO uses coroutines and an event loop to overlap waiting operations without assigning one operating-system thread to every task.

Async works best when the libraries involved are genuinely asynchronous:

  • httpx.AsyncClient;
  • aiohttp;
  • async database drivers;
  • async Redis clients;
  • other asyncio-compatible libraries.

An async def declaration alone does not make code concurrent. Concurrency appears when multiple coroutines are scheduled and yield control while waiting.

Running Async Code from a Lambda Handler

For a standalone Python Lambda function, a simple pattern is to keep the Lambda handler synchronous and run an async entry point:

import asyncio


async def main(event):
    return {
        "message": "Hello from async code",
        "event": event,
    }


def lambda_handler(event, context):
    return asyncio.run(main(event))

asyncio.run() creates an event loop, executes the coroutine, finalizes asynchronous generators, and closes the loop.

This is straightforward when the handler owns the async lifecycle. Framework integrations that already manage an event loop should manage that lifecycle themselves instead.

Concurrent Async Requests

Async becomes useful when several operations can be in flight together.

import asyncio

import httpx


async def fetch_json(client, url):
    response = await client.get(url, timeout=3)
    response.raise_for_status()
    return response.json()


async def main():
    urls = [
        "https://api.example.com/profile",
        "https://api.example.com/orders",
        "https://api.example.com/permissions",
    ]

    async with httpx.AsyncClient() as client:
        profile, orders, permissions = await asyncio.gather(
            *(fetch_json(client, url) for url in urls)
        )

    return {
        "profile": profile,
        "orders": orders,
        "permissions": permissions,
    }


def lambda_handler(event, context):
    return asyncio.run(main())

If all three requests take roughly 300 ms, the total waiting time can approach the slowest request rather than the sum of all three, plus connection and scheduling overhead.

Limit Async Concurrency

A common mistake is creating one task for every item without considering downstream capacity.

For example, 1,000 input records should not automatically become 1,000 simultaneous HTTP requests.

A semaphore can bound concurrency:

import asyncio

import httpx


async def fetch(client, semaphore, url):
    async with semaphore:
        response = await client.get(url, timeout=3)
        response.raise_for_status()
        return response.json()


async def fetch_all(urls):
    semaphore = asyncio.Semaphore(20)

    async with httpx.AsyncClient() as client:
        return await asyncio.gather(
            *(fetch(client, semaphore, url) for url in urls)
        )

The right limit depends on downstream rate limits, connection pools, Lambda memory, timeout budgets, and workload behavior.

Concurrency is a resource budget, not a value to maximize.

Event Loops in AWS Lambda

The event loop schedules coroutines and resumes them when the operations they are waiting for can continue.

Task A starts HTTP request
        ↓
Task A waits

Task B runs
        ↓
Task B starts database request
        ↓
Task B waits

Task C runs
        ↓
Task C waits

HTTP response for A arrives
        ↓
Task A resumes

The event loop does not make an external API respond faster. It makes the waiting period available for other work.

What Happens During await?

Consider:

async def get_user(client, user_id):
    response = await client.get(
        f"https://api.example.com/users/{user_id}"
    )

    return response.json()

When the coroutine reaches an awaitable operation that is not ready, it can yield control to the event loop.

get_user()
    ↓
start HTTP operation
    ↓
await
    ↓
coroutine suspended
    ↓
event loop runs other ready work
    ↓
HTTP operation becomes ready
    ↓
coroutine resumes

This cooperative behavior is why async can support many waiting tasks with relatively low scheduling overhead.

Blocking the Event Loop

Blocking code inside an async coroutine blocks the thread running the event loop.

This looks asynchronous but is not:

import requests


async def get_user():
    response = requests.get(
        "https://api.example.com/users/1",
        timeout=3,
    )

    return response.json()

requests.get() is synchronous. While it waits, the event-loop thread cannot use that time to run other coroutines.

Use an asynchronous client:

import httpx


async def get_user(client):
    response = await client.get(
        "https://api.example.com/users/1",
        timeout=3,
    )

    return response.json()

Another option is to deliberately move unavoidable blocking work to a thread:

import asyncio

import requests


def blocking_request():
    return requests.get(
        "https://api.example.com/users/1",
        timeout=3,
    ).json()


async def get_user():
    return await asyncio.to_thread(blocking_request)

This does not turn the library into an async library. It prevents the blocking call from occupying the event-loop thread.

Event Loop Lifecycle

Event-loop ownership should be clear.

Standalone Lambda

lambda_handler()
      ↓
asyncio.run()
      ↓
main()
      ↓
async work


ASGI Lambda

API Gateway
      ↓
Lambda
      ↓
Mangum
      ↓
FastAPI / ASGI lifecycle

Creating independent event loops deep inside helper functions makes lifecycle management, cancellation, and resource ownership difficult to reason about.

Keep async orchestration near the application boundary and let frameworks manage their own event-loop integration.

Threads vs Async in Lambda

Threads and async both improve IO concurrency, but they fit different libraries and application structures.

Characteristic Threads Async IO
Execution model Multiple OS threads Coroutines on an event loop
Best fit Blocking IO libraries Async-compatible libraries
boto3 Natural fit for concurrent calls Blocking unless adapted or another client is used
requests Natural fit Blocks event loop if called directly
Large numbers of waiting operations Thread count becomes expensive Often more efficient
Shared-state risk Thread synchronization required Interleaving still requires careful state design
CPU-heavy Python Usually poor primary solution Does not solve CPU saturation

A practical decision sequence is:

Is sequential execution fast enough?
        │
       yes
        ↓
Keep it simple

        no
        ↓
Is the bottleneck IO?
   │             │
  no            yes
   ↓             ↓
Tune CPU      Blocking library?
or compute      │       │
               yes      no
                ↓        ↓
             Threads   Async

Mixing the two models can also be appropriate. An async application may use a small thread pool for a blocking dependency that cannot be replaced.

FastAPI and Mangum in Lambda

FastAPI is an ASGI framework with native support for asynchronous route handlers. Mangum adapts Lambda events from sources such as API Gateway to the ASGI interface expected by the application.

API Gateway
     ↓
AWS Lambda
     ↓
Mangum
     ↓
FastAPI
     ↓
Route handler
from fastapi import FastAPI
from mangum import Mangum


app = FastAPI()


@app.get("/health")
async def health():
    return {"status": "ok"}


handler = Mangum(app)

With this architecture, event-loop integration belongs to the ASGI stack. Calling asyncio.run() from individual routes is not the normal pattern.

Async FastAPI Routes

An async route is useful when it awaits async IO:

import httpx
from fastapi import FastAPI


app = FastAPI()


@app.get("/users/{user_id}")
async def get_user(user_id: int):
    async with httpx.AsyncClient() as client:
        response = await client.get(
            f"https://api.example.com/users/{user_id}",
            timeout=3,
        )

    response.raise_for_status()
    return response.json()

For production applications, reusable client lifetime should also be considered instead of automatically creating a new HTTP client for every request.

Blocking Code in Async Routes

An async def route does not make a synchronous dependency asynchronous.

import boto3
from fastapi import FastAPI


app = FastAPI()
s3 = boto3.client("s3")


@app.get("/report")
async def report():
    # boto3 call is still synchronous.
    response = s3.get_object(
        Bucket="reports",
        Key="latest.json",
    )

    return {
        "size": response["ContentLength"],
    }

If a blocking operation is significant, it should be handled according to the framework's concurrency model or deliberately moved away from the event-loop thread.

Making every function async without examining the libraries underneath it can produce an application that looks asynchronous while still serializing on blocking IO.

Production Design Example

Consider an API Lambda that builds a customer dashboard from four independent sources:

DynamoDB profile       35 ms
DynamoDB permissions   30 ms
Orders API            180 ms
Billing API           220 ms

The original implementation performs every call sequentially:

Profile
  ↓
Permissions
  ↓
Orders
  ↓
Billing
  ↓
Response

Remote waiting ≈ 465 ms

CloudWatch measurements show that local processing takes only 15 ms. Most execution time is network waiting.

The first version uses boto3 and requests, so a bounded thread pool is the smallest architectural change:

from concurrent.futures import ThreadPoolExecutor


def load_dashboard(user_id):
    jobs = [
        lambda: get_profile(user_id),
        lambda: get_permissions(user_id),
        lambda: get_orders(user_id),
        lambda: get_billing(user_id),
    ]

    with ThreadPoolExecutor(max_workers=4) as executor:
        futures = [executor.submit(job) for job in jobs]
        return [future.result() for future in futures]

The approximate remote wait falls toward the slowest dependency:

Before: ~465 ms
After:  ~220 ms + overhead

Later, the HTTP integrations move to an async client. The AWS SDK operations remain synchronous.

A hybrid design becomes reasonable:

Lambda invocation
        │
     async main
        │
   ┌────┴──────────────┐
   ↓                   ↓
Async HTTP calls    blocking AWS calls
   │                   │
event loop           thread workers
   └────────┬──────────┘
            ↓
        aggregate
            ↓
         response

The concurrency limit is deliberately kept at eight operations per invocation.

This matters because Lambda itself may already be running hundreds of concurrent invocations:

500 Lambda invocations
×
8 internal operations
=
up to 4,000 downstream operations in flight

Invocation-level concurrency therefore multiplies Lambda-level concurrency.

This is one of the most important production consequences of adding threads or async to Lambda. A local optimization can become a downstream overload mechanism.

Useful metrics include:

  • Lambda duration p50, p95, and p99;
  • Lambda concurrent executions;
  • function timeout count;
  • downstream latency;
  • downstream error and throttling rates;
  • connection-pool utilization;
  • thread or async concurrency limit;
  • retry count;
  • memory usage;
  • CPU utilization where observable through application telemetry.

For broader tuning beyond concurrency, see AWS Lambda Performance Optimization.

SQS Batch Processing

SQS is another common place where internal concurrency can help. A Lambda invocation can receive several records in one batch, and each record may require independent IO.

SQS batch
   │
   ├── Record A → HTTP call
   ├── Record B → HTTP call
   ├── Record C → HTTP call
   └── Record D → HTTP call

Sequential processing can waste much of the invocation waiting for one message at a time.

But batch concurrency changes failure handling. If four messages run concurrently and one fails, the application needs to know which work succeeded and which work should be retried.

Concurrency should therefore be designed together with:

  • partial batch failure handling;
  • idempotency;
  • visibility timeout;
  • retry policy;
  • dead-letter queue configuration;
  • downstream capacity.

Do not add parallel batch processing without considering redelivery. A retry of already completed non-idempotent work can be worse than the latency that concurrency was intended to remove.

Timeouts, Cancellation, and Partial Failures

Concurrent code changes the shape of failure.

Sequential code might fail before later operations even start:

A succeeds
   ↓
B fails
   ↓
C never starts

Concurrent code can have several operations already in flight:

A succeeds ─────┐
B fails ────────┼→ aggregation
C still running ┤
D succeeds ─────┘

The application must decide whether one failed operation invalidates the entire result, whether partial data is acceptable, and whether remaining tasks should be cancelled.

Timeouts should exist at the dependency level rather than relying only on the Lambda timeout.

Lambda timeout: 10 seconds

HTTP timeout:     2 seconds
Database timeout: 1 second
Retry budget:     bounded

A dependency should fail early enough that the function still has time to handle the failure, return an appropriate result, or perform required cleanup.

Retries also multiply concurrency. A function that starts ten concurrent requests and retries each three times can create far more downstream traffic during an outage.

Production retry, timeout, and backoff strategies are covered in Timeouts, Retries, and Exponential Backoff.

Common Mistakes

  • Confusing Lambda concurrency with Python concurrency. More Lambda execution environments and more tasks inside one invocation solve different problems.
  • Adding concurrency before measuring the bottleneck. Sequential code is easier to operate when it already meets latency requirements.
  • Using blocking libraries directly inside async code. This can block the event-loop thread.
  • Assuming async makes code parallel. Async primarily overlaps waiting operations.
  • Using threads for CPU-heavy Python expecting linear CPU scaling.
  • Launching unbounded async tasks. Thousands of tasks can overwhelm downstream services and connection pools.
  • Using excessively large thread pools. More workers can increase memory use, contention, and throttling.
  • Ignoring the multiplication effect. Internal concurrency is multiplied by the number of concurrent Lambda invocations.
  • Creating event loops throughout helper functions. Event-loop ownership should remain clear.
  • Assuming background threads or tasks will reliably finish after the handler returns.
  • Ignoring partial failures in concurrent batch processing.
  • Using retries without a total retry budget. Retries can amplify downstream outages.
  • Creating new network clients unnecessarily on every operation. Reuse connections and clients when the library supports it safely.
  • Forgetting per-dependency timeouts. The Lambda timeout should not be the first mechanism that stops a stalled request.

Production Checklist

  • Measure whether the workload is IO-bound or CPU-bound.
  • Keep synchronous code when concurrency provides no meaningful improvement.
  • Use bounded thread pools for blocking IO libraries.
  • Use async only with libraries that can participate correctly in the async model.
  • Keep event-loop ownership clear.
  • Do not block the event-loop thread with synchronous network IO.
  • Set explicit HTTP, database, and SDK timeouts.
  • Limit async concurrency with semaphores or equivalent controls.
  • Size thread pools deliberately.
  • Account for Lambda concurrency multiplied by per-invocation concurrency.
  • Respect downstream connection and rate limits.
  • Reuse clients and connections when safe.
  • Handle concurrent exceptions deliberately.
  • Design SQS batch processing for partial failures and redelivery.
  • Use idempotency when work can be retried.
  • Do not depend on background work continuing after the handler returns.
  • Measure p95 and p99 duration, not only averages.
  • Load-test downstream systems with realistic Lambda concurrency.
  • Compare latency and cost before and after adding concurrency.

Frequently Asked Questions

Lambda combines two different concurrency models: AWS can run many function invocations simultaneously, while application code can perform concurrent work inside each invocation. Keeping those layers separate makes most design decisions easier.

Does Async Make Lambda Scale Better?

Async does not control Lambda's horizontal scaling. AWS Lambda scales by running additional execution environments according to the service's concurrency and scaling behavior.

Async can make an individual invocation more efficient when that invocation needs to wait for several independent IO operations.

Should boto3 Use Threads or Async?

boto3 exposes synchronous APIs, so a bounded thread pool is a straightforward way to overlap several independent boto3 calls inside one invocation.

An async application can also isolate unavoidable blocking work in threads rather than calling it directly on the event-loop thread.

Is asyncio.run() Safe in Lambda?

For a standalone synchronous Lambda handler that owns its async lifecycle, asyncio.run() is a simple way to execute an async entry point.

Frameworks such as FastAPI running through an ASGI adapter have their own lifecycle and should normally manage their event-loop integration instead.

Does Async Help CPU-Bound Lambda Functions?

Not by itself. A CPU-bound coroutine that does not yield for IO occupies the event-loop thread just like ordinary synchronous CPU work.

For CPU-heavy functions, evaluate Lambda memory and CPU allocation, optimized native libraries, workload decomposition, or another compute service depending on the workload.

Can Background Tasks Continue After the Handler Returns?

Reliable application logic should not depend on threads or async tasks continuing after the Lambda handler has returned. The execution environment lifecycle is controlled by Lambda.

Work that must happen reliably after the response should be handed to a durable asynchronous mechanism such as SQS, EventBridge, Step Functions, or another appropriate processing component.

Conclusion

AWS Lambda already provides concurrency across invocations. Threads, async, await, and event loops address a different problem: reducing waiting time inside one invocation.

Threads fit blocking libraries such as boto3 and requests. Async IO fits applications built around async-compatible HTTP, database, and Redis clients. Neither approach is a general solution for CPU-heavy Python workloads.

The most important production constraint is downstream capacity. Ten concurrent operations inside one function may look harmless until hundreds of Lambda environments perform them simultaneously.

Key takeaway: start with sequential code, measure where the invocation waits, introduce bounded concurrency only where it materially improves latency, and treat downstream capacity, timeouts, retries, and failure handling as part of the concurrency design.

Comments (0)