AWS Lambda is AWS's function-as-a-service platform. You upload code, connect it to an event source, and Lambda runs it on demand, scaling from zero to many parallel copies. To use it well you need three mental models: the handler contract, the execution environment that runs your code, and how concurrency and retries work. For the general idea of serverless, see Serverless and FaaS.
The pieces of a Lambda setup#
Before the three models, it helps to see what a working function is made of. It involves a few connected parts.
| Part | Role |
|---|---|
| Function | Your code plus its configuration (memory, timeout, environment variables) |
| Runtime | The language environment, such as Python, Node.js or Java, or a custom runtime |
| Event source | What triggers the function: API Gateway, S3, SQS, EventBridge and others |
| Execution role | An IAM role that grants the function permission to call other services |
| Downstream resources | Services the function calls, such as DynamoDB or S3 |
| Logs | Output written to CloudWatch Logs, one log stream per execution environment |
The handler, event and context#
The first mental model is the contract between Lambda and your code. The handler is the function Lambda calls for each invocation. You name it in the configuration, for example app.handler for a function handler in app.py. Lambda passes two arguments.
event: the input data. Its shape depends on the source: an S3 event lists the bucket and key, and an API Gateway event carries the HTTP method, path, headers and body.context: runtime information, such as the request ID, function name, memory limit and remaining time.
The return value goes back to the caller. Its required shape also depends on the source; API Gateway, for example, expects a status code and a body.
Here's a handler for an API Gateway route that looks up an order in DynamoDB. Notice where the table client is created.
import json
import logging
import os
import boto3
logger = logging.getLogger()
logger.setLevel(logging.INFO)
# Init code runs once per execution environment, then gets reused.
TABLE = boto3.resource("dynamodb").Table(os.environ["TABLE_NAME"])
def handler(event, context):
logger.info("request %s", context.aws_request_id)
order_id = event["pathParameters"]["id"]
item = TABLE.get_item(Key={"id": order_id}).get("Item")
if context.get_remaining_time_in_millis() < 1000:
logger.warning("close to timeout")
if item is None:
return {"statusCode": 404, "body": json.dumps({"error": "not found"})}
return {"statusCode": 200, "body": json.dumps(item, default=str)}
Three habits show in this example:
- Create clients outside the handler. Init code runs once per environment, so later invocations reuse the connection.
- Read config from environment variables. Lambda encrypts them at rest. For real secrets, fetch them from Secrets Manager or Parameter Store rather than storing them as plain variables.
- Log through the standard logger. Lambda sends stdout and stderr to CloudWatch Logs automatically.
Execution environments: cold and warm starts#
The example's comment about init code hints at the second mental model. Lambda runs your code in an execution environment: an isolated micro-VM with your runtime, code and a /tmp directory. An environment goes through three phases.
- Init: Lambda creates the environment, starts the runtime and runs your module-level code.
- Invoke: Lambda calls the handler with an event. After it returns, Lambda freezes the environment and may reuse it for the next event.
- Shutdown: after a period with no work, or when Lambda chooses, the environment is destroyed.
A cold start is an invocation that has to wait for the Init phase. A warm start reuses an existing environment and skips Init. Cold starts happen on the first call, after a code or config update, after idle periods, and whenever traffic grows past the warm environments you have. Lambda decides when to reuse or replace environments, so never rely on reuse for correctness.
Warm starts are why the init habit pays off. These things survive between warm invocations of one environment:
- Module-level objects, such as clients and caches.
- Files in
/tmp. - Background threads (frozen between calls).
Treat these as a cache, not as storage. Different environments do not share them, and any environment can disappear.
Reducing cold start impact#
Cold starts last from well under a second to several seconds, depending on runtime, package size and init work. You can't remove cold starts, but several levers shrink their impact:
- Keep init light: smaller packages, fewer imports, lazy-load rarely used libraries.
- Provisioned concurrency: keeps a set number of environments initialized and ready (see below).
- SnapStart: for supported runtimes (Java, and newer Python and .NET versions), Lambda snapshots an initialized environment and resumes new ones from it.
You may see an older trick in existing code: "keeping warm" by pinging a function on a schedule. It is dated: it keeps only one or a few environments warm and does nothing for bursts. Prefer provisioned concurrency or SnapStart.
Resources and limits#
Init time is one cost of each environment. Its size is another, and you control it through memory. You choose a memory size, and Lambda allocates CPU and network in proportion to it. More memory often makes CPU-bound functions faster, which can lower total cost.
| Setting | Range |
|---|---|
| Memory | 128 MB to 10,240 MB |
| CPU | Scales with memory; about one full vCPU at 1,769 MB |
/tmp storage |
512 MB by default, configurable up to 10,240 MB |
| Timeout | Up to 15 minutes |
Work longer than 15 minutes belongs in a container service or a workflow that chains several functions.
Concurrency#
The third mental model covers what happens when many events arrive at once. Concurrency is the number of requests your function is handling at the same moment. Each execution environment handles one request at a time, so if 50 requests are in flight, Lambda runs 50 environments. That removes threading bugs inside one environment, but it means bursts create many environments.
That makes concurrency easy to estimate from numbers you already track:
At 200 requests per second with a 0.25-second average duration, you need about 50 concurrent environments.
Concurrency is capped per account per Region. The default quota is 1,000 concurrent executions, and you can request more. All functions in the Region share that pool. When a function cannot get more concurrency, Lambda throttles: synchronous callers get a 429 TooManyRequestsException, and asynchronous events wait and retry.
Reserved vs provisioned concurrency#
The account pool is shared, so one busy function can starve the others. Two per-function settings help, and they solve different problems.
| Reserved concurrency | Provisioned concurrency | |
|---|---|---|
| What it does | Sets aside part of the account pool for this function and caps it there | Keeps a number of environments initialized before requests arrive |
| Fixes | Noisy neighbors, and protects downstream systems from overload | Cold start latency |
| Extra cost | None | Yes, you pay while it is configured |
| Special case | Setting it to 0 stops all invocations | Can follow a schedule or target with Application Auto Scaling |
Use reserved concurrency to cap a function that writes to a small database, or to guarantee capacity for a critical one. Use provisioned concurrency for latency-sensitive APIs.
Invocation types and retries#
Concurrency covers how many copies run. The last piece is what happens when one of them fails, and that depends on how the function was invoked.
| Invocation | Examples | Who retries |
|---|---|---|
| Synchronous | API Gateway, direct Invoke calls |
The caller; Lambda returns the error |
| Asynchronous | S3 notifications, SNS, EventBridge | Lambda, from an internal queue |
| Event source mapping (polling) | SQS, Kinesis, DynamoDB Streams | Lambda's poller, following the source's rules |
Asynchronous retries, DLQs and destinations#
For asynchronous invocations, Lambda queues the event and returns success to the sender at once. If the function fails, Lambda retries it two more times by default, waiting longer between attempts. You can lower the retry count to 0 or 1 and set a maximum event age.
If every attempt fails, the event is dropped unless you capture it:
- A dead-letter queue (DLQ), an SQS queue or SNS topic, receives the failed event.
- An on-failure destination receives the event plus error details. Destinations can be SQS, SNS, another Lambda function or EventBridge. An on-success destination can route successful results too.
Destinations carry more context than a DLQ, so they are the better default for new functions.
For SQS sources, a failed message goes back to the queue after its visibility timeout and is retried. Configure a redrive policy on the queue itself so poison messages move to a DLQ.
Retries mean the same event can run more than once. Make handlers idempotent: record a processed event ID, or use conditional writes, so a repeat has no extra effect.
Pitfalls#
Most Lambda bugs come from treating environments as long-lived servers.
- Heavy work in the handler instead of init repeats setup on every call.
- Relying on
/tmpor globals for state breaks when Lambda starts a new environment. - No concurrency cap in front of a small database lets a burst open thousands of connections.
- Async failures with no DLQ or destination vanish silently.
- Non-idempotent handlers double-charge or double-send on retry.
Key takeaways#
- Lambda calls your handler with an
event(input) and acontext(runtime info); put setup code outside the handler. - Environments are reused for warm starts; a cold start runs the Init phase first. Never depend on reuse.
- Memory goes up to 10,240 MB with CPU in proportion;
/tmpis configurable up to 10,240 MB; timeout is at most 15 minutes. - One environment serves one request at a time; concurrency is roughly rate times duration.
- Reserved concurrency caps and guarantees; provisioned concurrency pre-warms and costs extra.
- Async invocations retry twice by default; capture failures with a DLQ or on-failure destination and keep handlers idempotent.