The problem
The photo app has two kinds of work. One is a small API: GET /photos/{id} has to answer quickly whether a few people open photos at night or hundreds do every second in the evening. The other is background work: every upload needs a thumbnail, and uploads come in bursts. Running servers for both means sizing a fleet for the evening peak and paying for it all night, patching its operating system and language runtime, and writing the code that pulls messages off a queue, retries them and adds workers when the queue grows. Neither job needs a server of its own. Each is a short piece of code that should run when a request or an event arrives, in as many copies as there are events at that moment.
How it works
AWS Lambda runs a function, your handler and its dependencies packaged as a .zip archive or a container image, whenever something invokes it. Lambda owns everything underneath: the hosts, the operating system, the language runtime, scaling, and routing each request to a copy of the code. The photo app has two functions: get-photo behind API Gateway, and make-thumbnail (Python, 1,024 MB of memory, a 60-second timeout) fed from the SQS queue thumbnail-jobs.
Three ways to invoke a function
How a function is invoked decides who waits, who retries and where a failed event ends up:
| Invocation | Used by | Who waits | When the handler fails |
|---|---|---|---|
Synchronous (InvocationType RequestResponse) |
API Gateway, function URLs, the SDKs and CLI | the caller, until the handler returns | The error goes back to the caller. Lambda doesn't retry, and API Gateway passes the error on to its client. |
Asynchronous (InvocationType Event) |
S3 and SNS notifications, EventBridge rules, EventBridge Scheduler | nobody: Lambda puts the event on its internal queue and answers 202 at once | Lambda retries twice, waiting 1 minute and then 2, and then sends a record to an on-failure destination, or the event to a dead-letter queue, or drops it. |
| Event source mapping (polling) | SQS, Kinesis, DynamoDB Streams, Amazon MSK and self-managed Kafka, Amazon MQ, Amazon DocumentDB | Lambda's pollers, which read a batch and invoke the function synchronously with it | From a queue, the batch becomes visible again after the visibility timeout, and the queue's redrive policy moves repeat failures to its own dead-letter queue. From a stream, the batch is retried and holds up its shard until it succeeds or the records expire. |
S3 could invoke make-thumbnail directly, asynchronously. The photo app sends the ObjectCreated events to thumbnail-jobs instead, so the queue absorbs bursts and the event source mapping decides batching, concurrency and retries.
The execution environment
Every invocation runs in an execution environment: a sandbox with the function's memory, its runtime and a /tmp directory. In Lambda's default compute type, environments are isolated from each other with Firecracker microVMs, the open-source virtual machine monitor that AWS described at NSDI 2020. An environment goes through three phases:
- Init. Lambda starts any extensions, bootstraps the runtime and runs the function's static code: everything outside the handler, such as imports, configuration and SDK clients. For on-demand functions this phase has 10 seconds; if it runs out, Lambda tries Init again during the first invocation, under the function's timeout.
- Invoke. Lambda runs the handler for one event, bounded by the function timeout (60 s for
make-thumbnail; 900 s at most). - Shutdown. When Lambda retires an environment, registered extensions get up to 2 seconds to clean up (500 ms if the only one is an internal extension).
After each invocation the environment is frozen. When the next request for the same function arrives, Lambda can thaw it and run only the handler: objects created during Init are still in memory, files in /tmp are still there, and background work left running resumes. Lambda keeps idle environments for a while but promises nothing. It removes idle ones, and it also replaces environments every few hours for runtime updates and maintenance, even for a function that is invoked all the time, so treat what survives as a cache, never as state.
Cold starts
A request that finds no idle environment waits for a new one: Lambda creates the environment, loads the code and runs Init before the handler. That is a cold start; reusing a frozen environment is a warm start. AWS's documentation says cold starts typically affect under 1% of invocations and take from under 100 ms to more than a second, depending mainly on the package size, the runtime and how much work Init does. Since 1 August 2025 the Init phase is billed as duration in every configuration. There are three ways to shorten or avoid it:
- Make Init cheaper: a smaller package, fewer imports, heavy libraries loaded only when needed.
- Provisioned concurrency keeps a set number of environments initialised in advance, 2 for
get-photo. You configure it on a version or an alias and pay for it for as long as it is configured; requests beyond it spill over to on-demand environments, which can start cold. - SnapStart runs Init when you publish a version, snapshots the memory and disk of the initialised environment, and starts new environments from that snapshot. As of October 2026 it supports Java 11 and later, Python 3.12 and later and .NET 8 and later. It can't be combined with provisioned concurrency, EFS or more than 512 MB of
/tmp, and anything unique created during Init (IDs, random seeds, credentials) has to be created again after a restore. It costs nothing extra for Java; Python and .NET functions pay for caching and for each restore.
Concurrency and scaling
In the default compute type an environment handles one request at a time, so concurrency, the number of environments busy at once, is about requests per second × average duration. At 50 uploads a second and 0.8 s per thumbnail, make-thumbnail keeps about 50 × 0.8 = 40 environments busy whatever the batch size: with full batches of 10, about 5 invocations start each second and each runs for about 8 s. The controls, with their defaults as of October 2026:
- Account quota. 1,000 concurrent executions per Region, shared by every function in the account, which can be raised to tens of thousands. New accounts start lower and AWS raises them automatically with use.
- Scaling rate. Each function can add up to 1,000 environments every 10 seconds.
- Reserved concurrency is both a cap and a guarantee for one function:
make-thumbnailnever runs more than 100 environments, and 100 are held back for it. Lambda always leaves at least 100 units unreserved for other functions, and reserved concurrency 0 stops a function altogether. It costs nothing. - SQS event source mappings scale their pollers separately. They start with 5 concurrent invocations and add up to 300 a minute, to at most 1,250 per mapping. Maximum concurrency (2 to 1,000) caps what one queue can drive; keep it at or below the function's reserved concurrency, or messages get throttled. Provisioned mode instead keeps a minimum and maximum number of pollers ready, for a fee.
- Throttling. When requests arrive faster than the function can scale, or a limit is reached, the invocation fails with HTTP 429 (
TooManyRequestsException). A synchronous caller gets the error; Lambda keeps asynchronous events and retries them for up to 6 hours; an event source mapping backs off and the messages wait in the queue.
Failures and at-least-once delivery
With ReportBatchItemFailures turned on for the mapping, make-thumbnail returns the IDs of the messages it couldn't process in batchItemFailures, and Lambda deletes the rest. Without it, any exception fails the whole batch and the good messages run again. A failed message becomes visible after the queue's visibility timeout, and once it has been received maxReceiveCount times, the redrive policy moves it to thumbnail-jobs-dlq. AWS recommends a visibility timeout of at least six times the function timeout (here 6 × 60 s = 360 s) and a maxReceiveCount of at least 5. Note that this dead-letter queue belongs to the SQS queue. Lambda's own dead-letter queue is for asynchronous invocations, and its on-failure destinations cover asynchronous invocations and stream and Kafka mappings, so neither applies to an SQS event source mapping.
Every path delivers at least once. S3 notifications can arrive more than once, SQS and event source mappings can deliver a message again, and even Lambda's asynchronous queue can hand a function the same event more than once. Handlers therefore have to be idempotent (see idempotent consumer). make-thumbnail is idempotent by design: it always writes the same key, thumbnails/u/…, for the same upload. Where a repeat would do harm, such as a charge or an email, the Idempotency utility of Powertools for AWS Lambda can store a key for each event in DynamoDB and returns the saved result when the same event comes back within a window (1 hour by default).
Where it fits
- Solutions. Serverless APIs (API Gateway or a function URL in front, DynamoDB behind); processing files as they land in S3; consumers of queues and streams; glue between AWS services, where an EventBridge rule or SNS topic invokes a function; scheduled jobs, which EventBridge Scheduler invokes asynchronously; and the individual steps of an AWS Step Functions workflow.
- Patterns in this catalog. Lambda is the usual way to build serverless functions and the consumers of an event-driven architecture. With SQS in front it gives queue-based load leveling and competing consumers without managing workers, which is web-queue-worker without the worker fleet; the queue's dead-letter queue and an idempotent handler complete it. Behind an API gateway it serves request and response APIs.
- Usual neighbours. API Gateway, Amazon SQS, SNS, EventBridge, Amazon S3, Amazon DynamoDB, AWS Step Functions, CloudWatch and X-Ray, and IAM: each function runs with an execution role that holds its permissions.
- Managed offerings. Lambda is itself the managed service. The closest equivalents are Azure Functions and Google Cloud Run functions.
When to use it
Use Lambda when work arrives as separate requests or events, finishes within seconds or minutes, and keeps its state somewhere else: APIs with uneven traffic, files processed as they arrive, queue and stream consumers, scheduled jobs, glue between AWS services. It is a strong default when traffic is low, spiky or unknown, because idle time costs nothing and there is no fleet to size.
Look elsewhere for work that runs longer than 15 minutes in one go (split it into Step Functions steps, or use durable functions, below), for steady heavy load where always-on capacity is cheaper (see the costs below), for latency-critical paths that can't absorb a cold start and won't pay for provisioned concurrency, and for software that needs long-lived connections or large in-memory state.
| AWS Lambda | Amazon ECS on Fargate | Kubernetes Deployment | Azure Functions (Flex Consumption) | Google Cloud Run functions | |
|---|---|---|---|---|---|
| You deploy | a function (.zip or container image) | a task definition (containers) | a pod template, on a cluster you run | a function app | a function, built into a Cloud Run service |
| Requests per instance | 1 per environment (several on Managed Instances) | as many as your app handles | as many as your app handles | several, set per function | up to 1,000 |
| Longest request | 15 minutes | no limit: tasks run until stopped | no limit | 30 minutes by default, no enforced maximum (HTTP responses within 230 s) | up to 60 minutes for HTTP functions |
| Largest instance | 10,240 MB, CPU in proportion | 32 vCPU, 244 GB | your node size | depends on the instance memory you pick | 4 vCPU, 16 GiB |
| Scaling | per request, down to zero | Service Auto Scaling on CloudWatch metrics | HorizontalPodAutoscaler on metrics, plus node scaling | per function, down to zero, up to 1,000 instances | per request, down to zero; minimum instances optional |
| You pay for | requests + GB-seconds, in 1 ms steps | vCPU and memory per second, from image pull to stop (1-minute minimum on Linux) | the nodes, busy or idle | executions + memory while executing, + any always-ready instances | Cloud Run pricing |
Trade-offs
- Cold starts show up in the tail. They hit the first request after a quiet period, every request that forces a new environment during a burst, and the first requests after each deployment. Provisioned concurrency avoids most of them, for a price you pay while it is configured; SnapStart makes them shorter.
- One request per environment. It keeps the programming model simple (no shared state between concurrent requests, no thread pools), but an I/O-bound function pays for its memory while it waits on the network, where a container would serve many requests with the same memory. Lambda Managed Instances, below, exist for steady, high-volume work of that kind.
- Hard limits (October 2026): 15 minutes per invocation; payloads of 6 MB each way for synchronous calls, 1 MB for asynchronous ones and 200 MB for streamed responses; a 250 MB unzipped package including layers, or a 10 GB container image;
/tmpup to 10,240 MB; 4 KB of environment variables. - Steady load costs more. In us-east-1, a 1,769 MB function (the documented equivalent of one vCPU) busy for a whole hour costs about $0.104 in duration on x86, while a Fargate task with 1 vCPU and 2 GB (Linux, x86) costs about $0.049 an hour. For CPU-bound work Lambda comes out ahead only while it is busy less than about half the time, and a container that serves many I/O-bound requests at once moves that point further.
- Scaling can overwhelm what's behind it. A burst that adds hundreds of environments can exhaust a database's connections or a partner's rate limit. Cap the function with reserved concurrency or the mapping's maximum concurrency, and put a connection pooler in front of relational databases.
- One quota for the whole account. Every function in a Region draws from the same pool, so a runaway function can throttle the others; reserved concurrency fences off the critical ones. Lambda's recursive loop detection stops loops that run through SQS, S3, SNS or EventBridge custom event buses after about 16 invocations, but not loops through other services.
- Retries at every layer. S3 delivers notifications at least once, SQS redelivers and the mapping retries, so duplicates are normal, and a message that keeps failing takes several visibility timeouts (here 5 × 360 s) to reach the dead-letter queue.
- Lock-in sits around the code. A handler is ordinary code; the triggers, event formats, IAM roles and the services around it are AWS-specific.
Implementation notes
Connect the queue. The event source mapping is created on the Lambda side; ReportBatchItemFailures turns on partial batch responses. The queue itself needs VisibilityTimeout 360 and a redrive policy with maxReceiveCount 5 pointing at thumbnail-jobs-dlq. The account ID and alias are examples:
aws lambda create-event-source-mapping \
--function-name make-thumbnail \
--event-source-arn arn:aws:sqs:us-east-1:111122223333:thumbnail-jobs \
--batch-size 10 \
--function-response-types ReportBatchItemFailures
aws lambda put-function-concurrency --function-name make-thumbnail \
--reserved-concurrent-executions 100
aws lambda put-provisioned-concurrency-config --function-name get-photo \
--qualifier live --provisioned-concurrent-executions 2
Keep expensive setup in Init and report failures per message. The S3 client is created once per environment; the handler reports only the messages that failed. Object keys arrive URL-encoded in S3 events, and S3 sends an s3:TestEvent message without Records when the notification is first set up:
import json
import urllib.parse
import boto3
s3 = boto3.client("s3") # Init: runs once per execution environment, reused while warm
def make_thumbnail(bucket, key):
... # download u/…, resize it, upload thumbnails/u/… (the same key every time)
def handler(event, context): # Invoke: one batch of up to 10 SQS messages
failures = []
for record in event["Records"]:
try:
body = json.loads(record["body"]) # the S3 event notification
for s3_event in body.get("Records", []): # s3:TestEvent has no Records
bucket = s3_event["s3"]["bucket"]["name"]
key = urllib.parse.unquote_plus(s3_event["s3"]["object"]["key"])
make_thumbnail(bucket, key)
except Exception:
failures.append({"itemIdentifier": record["messageId"]})
return {"batchItemFailures": failures} # needs ReportBatchItemFailures on the mapping
- Memory is also the CPU setting. Lambda allocates CPU in proportion to memory, and 1,769 MB is the equivalent of one vCPU, so
make-thumbnailat 1,024 MB gets a little over half of one. CPU-bound work such as resizing images often runs faster with more memory, which can leave the cost per photo about the same; measure before you settle on a size. - Arm costs less per GB-second. Functions on the
arm64architecture (AWS Graviton2) are priced at $0.0000133334 per GB-second in us-east-1, against $0.0000166667 onx86_64, 20% less. Native dependencies need arm64 builds. - Work out the bill. Lambda charges $0.20 per million requests plus duration × memory (us-east-1, first pricing tier, October 2026). A million thumbnails at 0.8 s and 1 GB is 800,000 GB-seconds, about $13.33 on x86 or $10.67 on Arm, plus $0.02 to $0.20 in requests depending on how full the batches are, before the monthly free tier (1 million requests and 400,000 GB-seconds) and without the SQS and S3 charges.
- Networking. By default a function can reach the internet and public AWS endpoints but not private resources in your VPC. Attached to private subnets, it reaches them through Hyperplane network interfaces that Lambda creates when you configure the function, one per combination of subnet and security group, shared by all environments. Once attached, it reaches the internet only if the VPC provides a route out, typically a NAT gateway.
- Layers, extensions and container images. A function can use up to 5 layers (.zip archives of libraries or data, for .zip functions only). Extensions run inside the environment next to the function, for monitoring agents or secret caches, and they add to Init and shutdown time. A container image can be up to 10 GB.
- Response streaming. Through a function URL, the
InvokeWithResponseStreamAPI or API Gateway's proxy integration, a function can stream a response of up to 200 MB: the first 6 MB at full speed, the rest at up to 2 MB/s. The managed Node.js runtimes support it directly; other languages need a custom runtime or the Lambda Web Adapter. - Observe it. Each invocation writes a
REPORTline to CloudWatch Logs withDuration,Billed DurationandMax Memory Used, plusInit Durationafter a cold start. Alarm on theErrors,ThrottlesandConcurrentExecutionsmetrics and on messages in the dead-letter queue, and turn on X-Ray tracing to follow a request across services. - Newer options (as described in the documentation in October 2026): Lambda Managed Instances (November 2025) run functions on EC2 instances in your account, with several invocations per environment, EC2 pricing plus a 15% management fee, and up to 90 minutes for asynchronous and most polled invocations. Lambda durable functions (December 2025) checkpoint progress inside a function so that one workflow can run for up to a year, and waits cost no compute. Lambda MicroVMs are a separate primitive: isolated environments for one user or job each, which live for up to 8 hours. Tenant isolation mode keeps each tenant's requests on environments used only for that tenant.