Most people meet AWS Lambda as "the thing you put code in and it runs." That description is true and it is also the reason people get the bill wrong, the latency wrong, and the scaling wrong. Lambda is not a place you put code. It is a specific execution model with a specific pricing contract, and almost everything that confuses people about it, cold starts included, falls out of that model directly.
This is the second article in the AWS Service Encyclopedia. The first covered Amazon EC2, the primitive everything else is built on. Lambda sits at the other end of that spectrum: you stop thinking about instances entirely and start thinking about invocations. The trade is real, and it is worth understanding before you commit an architecture to it.
What it actually is
AWS Lambda runs your code in response to events without you provisioning or managing servers. You upload a function, you attach a trigger (an HTTP request through API Gateway, a message on a queue, an object landing in S3, a scheduled rule), and Lambda runs the function once per event. You are billed for the number of invocations and the time each invocation runs, rounded to the millisecond. When nothing is calling your function, you pay nothing and there is nothing running.
The part the one-line description hides is where the code runs. Every invocation runs inside an execution environment: a small, isolated microVM built on Firecracker, the same virtualization technology AWS open-sourced for exactly this. That environment has your code, your chosen runtime, a slice of memory you configure, and a proportional slice of CPU. Lambda creates these environments on demand and reuses them. The entire behavior of the service, including the cold start everyone complains about, is the story of how those environments get created, reused, and torn down.
The model
An invocation goes through two phases. The Init phase loads your function's code, starts the runtime, and runs everything you declared outside the handler (imports, SDK clients, config loading). The Invoke phase runs your handler against the actual event. When Lambda has to build a fresh environment before it can invoke, the Init phase runs first and the caller waits for it. That wait is the cold start. It is not a defect. It is the cost of the environment not existing yet.
Once the Init phase finishes, the environment stays warm and Lambda reuses it for the next request to the same function, skipping Init entirely. This is why your second request is fast and your first one after a quiet period is slow. It is also why anything expensive you can do once (open a database pool, construct an SDK client) belongs outside the handler: initialization code runs once per environment, handler code runs once per request.
Concurrency is the concept that ties this together, and it is where most cost and scaling mistakes live. Concurrency is the number of requests your function is handling at the same instant, not the number of requests per second. AWS states the relationship as a formula worth memorizing:
Concurrency = (average requests per second) * (average request duration in seconds)
A function handling 100 requests per second at 500 ms each has a concurrency of 50, because each environment clears two requests per second and you need 50 of them running in parallel. Halve the duration and you halve the concurrency for the same throughput. This is the single most useful lever you have: making a function faster does not just cut duration cost, it cuts how many environments you occupy, which is what you are actually rationing.
Each concurrent request needs its own environment, so Lambda scales by creating more of them. By default your account gets 1,000 concurrent executions across all functions in a Region, shared on demand. Two controls let you manage that pool. Reserved concurrency carves out a guaranteed slice for a function and acts as both a floor and a ceiling: the reserved function always has that capacity and can never exceed it, and no other function can borrow it. It costs nothing and is how you stop one runaway function from starving the rest, or stop a function from overwhelming a fragile downstream database. Provisioned concurrency is the other control, and it is the one that changes latency: it pre-initializes a set number of environments so they are warm before the first request arrives. Provisioned concurrency is how you buy your way out of cold starts, and unlike reserved concurrency, it costs money whether or not anything calls the function.
What AWS operates here is everything below your code: the microVMs, the scaling, the patching of the underlying OS and runtime, the load balancing across Availability Zones. What you operate is the code, the memory setting (which also sets your CPU), the timeout, the IAM role the function assumes, and the concurrency controls. You do not get to see or SSH into the environment. That opacity is the deal.
When to use it, when not to
Lambda is the right tool for event-driven, spiky, or low-to-medium-throughput workloads where you would rather pay per invocation than pay for an idle server. It is the wrong tool for steady high-throughput services where a reserved instance is cheaper, for anything that must run longer than 15 minutes, and for workloads that need to hold state in memory across requests.
The three services people confuse it with are EC2, Fargate, and App Runner. Here is the honest comparison.
Dimension | AWS Lambda | AWS Fargate (ECS) | Amazon EC2 |
|---|---|---|---|
Unit of deployment | A function | A container task | A virtual machine |
Max run time | 15 minutes per invocation | Unbounded | Unbounded |
Scales to zero | Yes, pay nothing when idle | No, tasks run until stopped | No, instance billed while running |
Cold start | Yes, unless provisioned | Task start latency (seconds) | None once running |
You manage | Code, memory, IAM | Container, task def, networking | OS, patching, scaling, everything |
Best fit | Spiky, event-driven, short jobs | Long-running containers, steady load | Full control, legacy, specialized hardware |
Billing granularity | Per ms of execution | Per second of task runtime | Per second of instance uptime |
The decision usually comes down to two questions. Does the work fit in 15 minutes, and is the traffic spiky enough that scaling to zero saves real money? Two yeses point to Lambda. A long-running process or a flat, predictable load points to Fargate or EC2, where you stop paying the cold-start tax and often pay less per unit of compute. AWS publishes a decision guide specifically for the Lambda-versus-Fargate call, which tells you how common the confusion is.
What it costs
Lambda has two headline pricing dimensions and one that ambushes people. You pay per request and per unit of compute time. In US East, requests are $0.20 per million. Compute is measured in GB-seconds: the memory you allocate multiplied by how long the function runs. On x86, the first 6 billion GB-seconds per month cost $0.0000166667 each, dropping to $0.0000150000 for the next 9 billion and $0.0000133334 beyond 15 billion under Lambda's tiered pricing. Arm/Graviton functions run the same duration roughly 20 percent cheaper, and switching architecture is often a one-line change, so Arm is the sensible default unless a dependency forces x86.
Work a real example. A 512 MB function that runs 200 ms, called one million times a month, costs $0.20 for the requests plus 100,000 GB-seconds of compute (1M invocations times 0.2 seconds times 0.5 GB) at $0.0000166667, which is about $1.67. Call it under two dollars a month for a million calls. This is why Lambda feels almost free at low volume and why the per-invocation model is so forgiving for spiky traffic.
The dimension that ambushes people is provisioned concurrency, because it inverts the whole billing story. Provisioned concurrency bills you for keeping environments warm whether or not they are used, at $0.0000041667 per GB-second in US East. Keep 10 environments warm at 512 MB for a 30-day month and you are paying 10 times 0.5 GB times $0.0000041667 times 2,592,000 seconds, which is about $54 a month, before you have served a single request. The whole appeal of Lambda is not paying for idle capacity; provisioned concurrency is you choosing to pay for idle capacity to kill cold starts. Sometimes that is the right call. Just know you have opted out of the model's best property when you turn it on.
The free tier historically included one million requests and 400,000 GB-seconds per month, and provisioned concurrency was never part of it. Note that AWS changed its free tier for accounts created after July 15, 2025, to a credit-based model, so if your account is new, verify what your plan actually grants before you assume the classic always-free Lambda allowance applies.
The limits that bite
Memory is configurable from 128 MB to 10,240 MB in 1 MB steps, and this setting is quietly a CPU setting too. Lambda allocates CPU in proportion to memory, so a function that is CPU-bound often runs faster and cheaper at higher memory because it finishes sooner. Benchmarking the memory setting is one of the highest-return tuning exercises available, and AWS Lambda Power Tuning exists precisely for this.
Timeout caps at 900 seconds, 15 minutes. There is no extension. If your work does not fit, you either break it into a Step Functions workflow or you move to Fargate. This is the single hardest wall in Lambda and the one that most often forces an architecture change.
Payload limits catch people mid-integration. A synchronous invocation (request plus response) is capped at 6 MB, and an asynchronous invocation at 256 KB. Large responses need response streaming or an S3 handoff. Ephemeral storage in /tmp ranges from 512 MB up to 10,240 MB, which matters for functions that download and process files. Deployment packages are limited to 50 MB zipped for direct upload and 250 MB unzipped, while container image functions can be up to 10 GB, which is the usual reason teams switch to the container packaging format.
The concurrency limits are the ones that cause production incidents. The default account limit is 1,000 concurrent executions per Region, and new accounts start lower. Independently, Lambda enforces a requests-per-second ceiling of 10 times your concurrency, so at the default 1,000 concurrency you cannot exceed 10,000 requests per second no matter how short your function is. A 20 ms function doing 30,000 requests per second only needs 600 concurrency by the formula, but it will still throttle on the RPS ceiling until you raise the account limit. On top of that, the scaling rate is capped: each function can add at most 1,000 environments every 10 seconds. A sudden traffic cliff can outrun that rate and throttle even when your account has headroom. Provisioned concurrency, which comes online in advance, is the defense against a spike you can predict.
Build it
We will create a small Python function, invoke it cold and measure the init latency, then attach provisioned concurrency to a published version and measure the difference. The deliverable is a real before-and-after latency number, not a claim.
Prerequisites. An AWS account, the AWS CLI v2 configured, Python 3.11+ locally, and permission to create IAM roles and Lambda functions. Estimated cost of following along: under $0.05 for the invocations, plus provisioned concurrency billed at under one cent per hour (about $0.0075) for the one warm environment at 512 MB. Delete it within the hour and the whole exercise costs pennies. All commands assume us-east-1; adjust the Region if you use another.
IAM. Lambda needs an execution role it can assume. Create the trust policy and role first.
cat > trust.json <<'EOF'
{"Version":"2012-10-17","Statement":[{"Effect":"Allow",
"Principal":{"Service":"lambda.amazonaws.com"},"Action":"sts:AssumeRole"}]}
EOF
aws iam create-role --role-name sl104-lambda-role \
--assume-role-policy-document file://trust.json
aws iam attach-role-policy --role-name sl104-lambda-role \
--policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
AWSLambdaBasicExecutionRole grants only the CloudWatch Logs permissions the function needs to write its logs. That is the least privilege a Lambda function requires. Give it a few seconds for the role to propagate before the next step.
The function. Write a handler with a deliberately slow init, so the cold start is visible. The time.sleep(2) outside the handler simulates the real-world cost of importing heavy libraries or building SDK clients.
# handler.py
import time
# This runs ONCE per environment, during the Init phase (the cold start).
time.sleep(2)
INITIALIZED_AT = time.time()
def handler(event, context):
# This runs once per request, during the Invoke phase.
return {"warm_for_seconds": round(time.time() - INITIALIZED_AT, 2)}
Package and create the function at 512 MB with a 10 second timeout, on Arm for the cheaper duration rate.
zip function.zip handler.py
ACCOUNT=$(aws sts get-caller-identity --query Account --output text)
aws lambda create-function --function-name sl104-coldstart \
--runtime python3.13 --architectures arm64 \
--handler handler.handler --timeout 10 --memory-size 512 \
--role arn:aws:iam::${ACCOUNT}:role/sl104-lambda-role \
--zip-file fileb://function.zip
Measure the cold start. Invoke once, then read the init duration Lambda reports in the logs. The --log-type Tail flag returns the last 4 KB of the invocation's log, base64 encoded, which includes the Init Duration line.
aws lambda invoke --function-name sl104-coldstart \
--log-type Tail --query LogResult --output text out.json \
| base64 --decode | grep -E "Init Duration|Duration"
You will see something like Init Duration: 2015.44 ms on that first call: the two-second sleep plus runtime startup, paid by the caller. Invoke a second time immediately and the Init Duration line is gone, because the environment is warm and Init did not run. That disappearing line is the cold start, made concrete.
Add provisioned concurrency. Provisioned concurrency only attaches to a published version or an alias, never to $LATEST. Publish a version, then allocate one warm environment to it.
VERSION=$(aws lambda publish-version --function-name sl104-coldstart \
--query Version --output text)
aws lambda put-provisioned-concurrency-config \
--function-name sl104-coldstart --qualifier "$VERSION" \
--provisioned-concurrent-executions 1
aws lambda wait provisioned-concurrency-config-updated \
--function-name sl104-coldstart --qualifier "$VERSION"
The wait command blocks until Lambda finishes pre-initializing the environment, which takes a minute or two. Once it returns, invoke the published version by qualifier and the Init phase has already happened offline. The caller never pays for it.
aws lambda invoke --function-name sl104-coldstart:${VERSION} \
--log-type Tail --query LogResult --output text out.json \
| base64 --decode | grep -E "Init Duration|Duration"
Verify it works. The contract for success is this: the unqualified function's first cold invocation shows an Init Duration around 2000 ms, and the provisioned version, after the wait returns, shows no Init Duration on its first invocation even though it had been idle. If you also want the concurrency numbers, aws lambda get-provisioned-concurrency-config --function-name sl104-coldstart --qualifier "$VERSION" should report Status: READY with AllocatedProvisionedConcurrentExecutions: 1. That is the whole point demonstrated end to end: provisioned concurrency moved the two-second init off the request path and onto your monthly bill.
When it breaks
The errors you will actually hit have specific meanings. A Rate exceeded or TooManyRequestsException on invoke means you hit either the account concurrency limit or the 10x requests-per-second ceiling; check which by comparing your concurrency against the formula before assuming you need more concurrency, because you may only need a higher RPS allowance. Task timed out after 10.00 seconds means your handler ran past the configured timeout, which for genuinely long work means moving to Step Functions or Fargate rather than just raising the timeout toward the 15-minute wall.
The provided execution role does not have permissions on the first invoke almost always means the role had not finished propagating when you created the function, or you attached the wrong policy; re-check that AWSLambdaBasicExecutionRole is attached. A ProvisionedConcurrencyConfigNotFoundException means you tried to invoke a qualifier that does not have provisioned concurrency, usually because you targeted $LATEST or an alias you never configured. And if provisioned concurrency seems to have no effect on latency, confirm you are invoking the qualified version (function-name:VERSION or an alias), because calls to the unqualified function still land on $LATEST and still cold-start.
The subtler failure is memory starvation showing up as slowness rather than an error. Because CPU scales with memory, an under-provisioned function does not crash, it just runs slowly and costs more in duration than a higher-memory setting would. If a function is mysteriously slow, raise the memory and re-measure before assuming the code is the problem.
Cleanup
Provisioned concurrency bills by the hour, so remove it first, then delete the function and role. This returns your account to a zero Lambda bill.
aws lambda delete-provisioned-concurrency-config \
--function-name sl104-coldstart --qualifier "$VERSION"
aws lambda delete-function --function-name sl104-coldstart
aws iam detach-role-policy --role-name sl104-lambda-role \
--policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
aws iam delete-role --role-name sl104-lambda-role
Confirm with aws lambda list-functions --query "Functions[?FunctionName=='sl104-coldstart']", which should return an empty list. The CloudWatch log group /aws/lambda/sl104-coldstart lingers with a few KB of logs at effectively no cost; delete it with aws logs delete-log-group --log-group-name /aws/lambda/sl104-coldstart if you want a truly clean slate.
One last note worth carrying forward. If cold starts are the problem but provisioned concurrency's always-on bill is unpalatable, Lambda SnapStart is the third option: it snapshots an initialized environment when you publish a version and resumes from that snapshot, cutting init to sub-second with no always-on charge on Java and a smaller per-restore charge on Python 3.12+ and .NET 8+. You cannot combine it with provisioned concurrency, and it only works on published versions, but for latency-sensitive functions on a supported runtime it is often the better answer than paying to keep environments warm around the clock.

