AWS Lambda Production Playbook
How we design, deploy, and operate Lambda-based systems in production: the workloads where it earns its keep, the limits we have actually hit, the architecture pattern that keeps code portable, and the decisions we make before committing to the runtime.
Key takeaways
- Lambda is the right runtime for short, stateless, event-driven jobs. It is the wrong runtime for large file processing, long-running transformations, and workloads that need warm in-memory state across invocations.
- Keep business logic in runtime-agnostic functions and treat the Lambda handler as a thin shell. This makes the code testable without mocking AWS, and portable when the runtime stops being the right fit.
- Account-level concurrency limits are shared across all functions in a region. We have hit a raised ceiling of roughly 4,000 concurrent executions on client work during traffic spikes.
- Cold starts are real but manageable. Provisioned concurrency eliminates them at a cost; minimizing package size and favoring Node or Python runtimes keeps them short in most cases.
- The IAM execution role for a Lambda function should be scoped to exactly the permissions the function needs and nothing else. Over-permissioned roles are the most common Lambda security mistake.
Published 2026-06-29
22 min read
Corsair Media Group
What Lambda is designed to do
Lambda's execution model is simple: an event arrives, a container spins up (or reuses a warm one), your code runs, and the container is returned to a pool or discarded. AWS handles everything outside that boundary — provisioning, scaling, patching, and availability. You pay only for the duration the code ran, rounded to the nearest millisecond.
That model rewards workloads with three characteristics. First, each invocation should complete in a reasonable amount of time relative to the 15-minute ceiling. Lambda supports long-running work — video transcoding, PDF generation, ETL, ML inference — but if your average execution time is approaching the limit, you are reducing the operational headroom available for retries and outliers. A workload that routinely runs for 13 minutes has almost no margin for a slow invocation before it times out. Second, each invocation should be stateless. Lambda containers do persist between invocations in practice, which allows you to reuse database connections or in-memory caches, but you cannot rely on that behavior. Code that depends on persistent state will behave unexpectedly when a new container is initialized. Third, the invocation pattern should be one where Lambda's per-request billing model provides a cost advantage. For most APIs and background processing workloads, automatic scaling and no idle compute make Lambda economical. For services with constant, high-throughput load where a fixed-size process would run at full utilization all day, a persistent compute option may be cheaper — though this depends on the specific workload and is worth modeling before assuming it.
Lambda sits in the middle of an ecosystem of triggers and destinations. The runtime handles scaling; your code handles the logic.
The diagram above reflects Lambda's position in a typical event-driven stack. People build surprisingly broad systems on Lambda, and the runtime is more flexible than its serverless label implies. That said, it has a specific cost and operational model, and the workloads where that model creates friction are worth knowing before the architecture is committed.
The thin-wrapper architecture pattern
The most important structural decision in any Lambda build is how to separate the handler from the business logic. This is not a style preference. It determines whether the code is testable without simulating an AWS event, and whether a future migration to a different runtime requires a full rewrite or just a new wrapper.
The pattern is straightforward. The handler is responsible for exactly two things: parsing the incoming AWS event into a typed input, and returning the typed output as the Lambda response. The business logic lives in a sibling module that takes the typed input and returns the typed output. That inner module has no import from the AWS SDK, no reference to the event format, and no knowledge that it is running inside Lambda.
The handler reads the event, calls the logic, and returns the result. The logic has no knowledge of Lambda, AWS, or the event format. When the runtime changes, only the wrapper changes.
In practice, this looks like two files. The first is handler.ts, which imports from aws-lambda and calls logic.ts. The second is logic.ts, which contains the real work and has no AWS imports at all. The test suite for the logic runs without any AWS mocking or event simulation. The handler itself is covered by a small number of integration tests that verify the parsing and response formatting.
When the runtime needs to change — because the workload has grown past Lambda's limits, because the team has consolidated onto a container platform, or because requirements have shifted — the migration is a new wrapper around the same logic. The engineering effort and testing investment in the business logic module moves intact. We have done this migration on client work, and the cost was proportional to the wrapper rather than to the system.
Choosing the right event source
Lambda's trigger is not an afterthought. The event source determines the invocation shape, the failure handling model, the retry behavior, and the observability you have over the pipeline. Choosing the wrong trigger for the job adds complexity that the runtime was not designed to absorb.
SQS as a trigger
SQS is the most common trigger for workloads that need reliable delivery and controlled retry behavior. Lambda's SQS integration polls the queue and delivers batches of messages to the handler. If the handler fails, the messages return to the queue and are retried according to the queue's visibility timeout and maximum receive count. A dead-letter queue captures messages that exhaust their retry budget.
The important configuration decision is batch size. A larger batch reduces the number of invocations and can lower cost, but a failure in the handler fails the entire batch and returns all messages to the queue. For workloads where individual message failures are common, smaller batches and partial batch response reporting (available since 2021) are worth the additional configuration.
EventBridge as a trigger
EventBridge is the right choice when the event needs to be routed to multiple consumers, or when the producer should be decoupled from knowing which Lambdas need to respond. The event bus accepts events from a producer, applies routing rules, and delivers matching events to registered targets — which may include Lambda functions, SQS queues, Step Functions, or external HTTP endpoints.
The routing rules are the key advantage. A single event published to the bus can trigger different Lambda functions based on its content, without the producer knowing anything about the consumers. This is more flexible than a direct SQS trigger when the fan-out pattern may evolve over time.
API Gateway as a trigger
API Gateway sits in front of Lambda for synchronous HTTP workloads. A request arrives at API Gateway, which invokes the Lambda function, waits for the response, and returns it to the caller. This is the standard pattern for serverless HTTP APIs.
The limitation is the cold start. API Gateway HTTP requests are synchronous, which means the caller waits for the Lambda container to initialize if one is not already warm. For latency-sensitive public APIs, provisioned concurrency or a container-based runtime may be more appropriate. For internal APIs with tolerant latency requirements, the cold start cost is usually acceptable.
Concurrency planning and the account-level limit
Lambda's concurrency model is the single most common source of production surprises in the systems we have worked on. The model itself is simple: each concurrent invocation runs in its own Lambda container. The complexity comes from the fact that concurrency is accounted at the region and account level, not per function.
Concurrency is not per-function by default. All functions in a region share the same account-level pool. If one function spikes, it can crowd out the others.
- Default limit: 1,000 concurrent executions per region
- Reserved concurrency: guaranteed allocation for a specific function
- Provisioned concurrency: pre-warmed instances, eliminates cold starts
- Production note: we have hit a raised ceiling of ~4,000 on client work
The default account concurrency limit is 1,000 concurrent executions per region. AWS will raise this limit on request, and we have clients operating at significantly higher ceilings. We have also hit a raised ceiling of roughly 4,000 concurrent invocations during a traffic spike on client work. The throttling was observable to end users — new invocations received a 429 response rather than entering a queue — and resolving it required a quota increase request that took time to process.
There are two tools for managing concurrency at the function level. Reserved concurrency allocates a fixed portion of the account limit to a specific function, guaranteeing availability but also capping what that function can consume. Provisioned concurrency pre-warms a set number of containers so they are always ready to respond without a cold start. Reserved and provisioned concurrency serve different purposes and can be used together.
The practical recommendation before any launch: model the expected concurrency for each function at peak load, sum it across all functions in the account, compare the total to your current quota, and request an increase with lead time if there is meaningful risk of hitting the ceiling. AWS processes quota increase requests, but not instantly, and a throttled API during a traffic event is not the right time to discover you need more headroom.
Memory, execution limits, and when they bite
Lambda's resource constraints are fixed, and the ones that matter most are the 10,240 MB memory ceiling and the 15-minute execution time limit. Most workloads will never approach either. The ones that do — not because they are poorly designed, but because the work is genuinely heavy — are worth identifying before the architecture is committed rather than after.
We built a system that processed large user-uploaded files through several transformation steps before producing an output artifact. Input files ranged from 50 MB to roughly 2 GB, with occasional outliers above that. We started at 3 GB of memory and worked up to the 10,240 MB ceiling. The largest files still produced out-of-memory errors. Execution times on the slow transformations pushed past five minutes and timed out near the 15-minute cap. We spent meaningful engineering time splitting the work into chunks just to fit it inside the Lambda envelope.
The lesson is direct. We eventually rebuilt the heavy portion of that pipeline on AWS Fargate, where a single container sized for the worst case could load the full file, transform it in one pass, and write the output without any chunking machinery. The Lambda wrapper pattern we had used meant the business logic moved without modification. The migration cost was a new handler for the Fargate task and the infrastructure to run it.
The decision framework is straightforward. If the worst-case input to your function requires more than 8 GB of memory to process comfortably, or if the slow path through the code could plausibly run for more than 10 minutes, then Lambda is not the right home for the heavy step. Plan for a container from the start rather than migrating under pressure later.
Cold start behavior and mitigation
A cold start occurs when Lambda must initialize a new container to serve an invocation. The initialization sequence has three phases: the container itself starts, the runtime environment initializes (downloading and unpacking the function package, loading the language runtime), and the handler initializes (running any code outside the handler function itself, such as database connection setup or module imports).
For a warm invocation — where an existing container handles the request — the handler initializes once and the only latency is the time the handler function itself takes to run. For a cold start, the full initialization sequence runs first. Cold start duration varies by runtime and package size. Node.js and Python cold starts are typically in the tens to low hundreds of milliseconds for a well-packaged function. JVM-based runtimes (Java, Kotlin, Scala) and large dependency bundles can add seconds.
Mitigation approaches
Provisioned concurrency is the most direct mitigation. It keeps a configurable number of containers initialized and ready to serve requests without going through the cold start sequence. The tradeoff is cost: you pay for provisioned concurrency whether or not requests are arriving. For latency-sensitive APIs, the cost is usually justified. For background processing workloads where a few hundred milliseconds of initialization latency is not observable to users, it is not.
SnapStart, available for Java functions, snapshots the initialized state of the container after initialization completes and restores from that snapshot on subsequent cold starts. It eliminates most of the JVM cold start cost without the ongoing expense of provisioned concurrency.
Package size has a direct effect on cold start duration because the runtime must download and unpack the deployment package before the function can run. Keeping dependencies lean — removing dev dependencies, using tree shaking for bundled runtimes, and preferring lightweight libraries — reduces this phase. A function packaged with 50 MB of dependencies will cold-start slower than one packaged with 5 MB.
Code outside the handler function runs during container initialization and contributes to cold start time, but it also persists across warm invocations. Database connection setup, configuration loading, and SDK client initialization belong in the module scope rather than inside the handler. They run once on cold start and are reused across the warm invocations that follow.
Designing a production Lambda system and want a second pair of eyes on the architecture?
Talk with CorsairIAM and the security model
Every Lambda function executes under an IAM execution role. The role is attached to the function at deploy time and grants the function's code permission to call other AWS services. Over-permissioned execution roles are the most common Lambda security mistake we see in production systems, usually because it is faster to attach a managed policy like AmazonDynamoDBFullAccess than to enumerate the specific actions the function actually needs.
The correct approach is least-privilege execution roles. Define a custom IAM policy that grants only the specific actions, on only the specific resources, that the function requires. A function that reads from one DynamoDB table should have permission to call dynamodb:GetItem and dynamodb:Query on that table's ARN. It should not have permission to write to it, scan it, or access any other table in the account.
Resource-based policies
In addition to the execution role (which controls what Lambda can call), Lambda supports resource-based policies that control what can invoke the function. When another AWS service invokes a Lambda function — API Gateway, SQS, EventBridge — the invocation is governed by a resource-based policy attached to the function. CDK and SAM handle this automatically for common trigger patterns, but it is worth verifying that the generated policies follow least-privilege rather than granting broad invoke permissions to any principal.
VPC placement
Lambda functions run outside a VPC by default. If the function needs to reach resources inside a VPC — an RDS instance, an ElastiCache cluster, or a private API — it can be configured to run inside one. VPC placement adds latency (a network interface must be allocated in the subnet) and cold start time, and it requires the function's security group to allow the relevant outbound traffic. For functions that do not need private network access, VPC placement adds complexity without benefit.
The practical guidance: start outside a VPC and add VPC placement only when a specific resource requires private network access. Do not place Lambda in a VPC as a default security posture — IAM provides the necessary access controls for most Lambda deployments without the networking overhead.
Observability: what to instrument and why
Lambda's managed runtime provides a baseline of observability without any configuration: CloudWatch captures invocation count, duration, error rate, throttle count, and concurrent execution count automatically. These metrics are available immediately after a function is deployed. They are sufficient to detect problems, but not sufficient to diagnose them.
Structured logging
CloudWatch Logs captures everything written to stdout and stderr. The difference between a system that is easy to debug in production and one that is not usually comes down to whether the logs are structured. Plain-text log messages require manual parsing to extract fields like request ID, user identifier, or processing duration. JSON-formatted logs can be queried directly in CloudWatch Logs Insights, filtered by field value, and aggregated into metrics.
At minimum, every invocation should log a structured record that includes the correlation ID (the Lambda request ID or a propagated trace ID), the input shape (not the full payload, but enough to identify what the function received), and the outcome (success, error type, processing duration). Errors should include the error message, the error type, and a stack trace.
AWS X-Ray
X-Ray provides distributed tracing across the services a Lambda function touches. When X-Ray tracing is enabled on a function and the downstream clients (DynamoDB, SQS, API calls) are instrumented with the AWS SDK's X-Ray integration, a single trace shows the full call graph: how long the Lambda invocation took, how long each downstream service call took, and where latency or errors occurred.
X-Ray is particularly useful for pipelines where Lambda is one step among several. A trace that spans API Gateway, Lambda, DynamoDB, and SQS makes it straightforward to determine whether a latency spike originated in the function itself or in a downstream dependency.
Alarms worth configuring from day one
The four CloudWatch alarms we configure by default on every production Lambda deployment are: error rate (trigger when the percentage of failed invocations exceeds a threshold), throttle count (trigger when any throttling occurs), duration approaching the timeout (trigger when p99 execution time is within a defined margin of the function timeout), and concurrent executions approaching the reserved or account limit.
Deployment: CDK, SAM, and Terraform
Lambda functions can be deployed through the AWS console, the AWS CLI, CDK, SAM, Terraform, and several third-party frameworks. The choice of deployment tool is usually driven by what the rest of the infrastructure uses rather than by Lambda-specific considerations.
AWS CDK
CDK is our default for greenfield AWS infrastructure. It represents infrastructure as code in TypeScript (or Python, Java, or Go), which means the same language, tooling, and review process as the application code. The Lambda construct handles packaging, IAM role generation, and event source mapping with a small amount of configuration. CDK also makes it straightforward to express the full event pipeline — Lambda function, SQS queue, EventBridge rule, DynamoDB table — as a coherent stack in one place.
AWS SAM
SAM is a Lambda-native template format built on CloudFormation. It is a reasonable choice when the deployment is Lambda-focused and the team is more familiar with CloudFormation's YAML syntax than with CDK's imperative model. SAM's local development tooling (sam local invoke and sam local start-api) allows testing the handler behavior locally against synthetic events without deploying, which is useful during development.
Terraform
Terraform is the right choice when the Lambda function is part of a larger infrastructure footprint managed in Terraform, or when the team's infrastructure expertise is in Terraform rather than CDK or CloudFormation. The Terraform AWS provider's aws_lambda_function resource covers Lambda deployment fully. The main consideration is that Terraform's packaging step for the deployment artifact requires additional configuration (typically using a null_resource or a dedicated module like terraform-aws-modules/lambda) that CDK and SAM handle automatically.
Regardless of the deployment tool, functions should be deployed through a CI/CD pipeline rather than from a developer's local machine. The pipeline is the right place for test execution, security scanning, artifact packaging, and environment promotion. Direct console or CLI deployments bypass these controls and are a common source of configuration drift in Lambda environments.
Cost model: when Lambda wins and when it does not
Lambda's cost model has two components: a per-request charge and a duration charge measured in GB-seconds (memory allocated multiplied by execution time in seconds). As of this writing, the per-request charge is $0.20 per million requests after the free tier, and the duration charge is approximately $0.0000166667 per GB-second.
The total cost of a Lambda deployment is almost never just the Lambda charges. API Gateway adds per-request and per-connection fees. EventBridge charges per event. SQS charges per API call. CloudWatch charges for log storage and log metric filters. Data transfer out of the Lambda function to the internet or to other regions adds to the bill. A cost model that includes only Lambda invocation costs is usually materially understated.
Lambda's cost advantage is clearest for workloads that run infrequently. A function that handles 500,000 invocations per month at 200 milliseconds each, using 256 MB of memory, costs less than a dollar per month in Lambda charges. A comparable persistent service on a small EC2 instance costs twenty dollars or more per month regardless of how many requests it handles. At low to moderate request volume, Lambda wins on cost almost by default.
The comparison shifts at high throughput. A Lambda function handling 100 million invocations per month at 500 milliseconds each, using 512 MB of memory, costs roughly $430 in Lambda duration charges alone, before API Gateway, data transfer, and other associated costs. A managed container sized for the same throughput may be cheaper at that volume. The specific crossover point depends on request duration, memory allocation, and the costs of the supporting services.
Our general rule: Lambda's cost model works well when idle time would otherwise be wasted — processing that fires in response to events rather than running continuously. For services with sustained high throughput, a persistent compute option often becomes cheaper, but this is not universal. Many high-traffic APIs run economically on Lambda because automatic scaling, no idle servers, and simpler operations offset the per-invocation cost. The right answer depends on your specific request volume, duration, and memory profile, and is worth modeling at production scale before committing either way.
When to move past Lambda
Lambda is not a permanent commitment. If the thin-wrapper pattern is in place, the migration cost of moving to a different runtime is proportional to the wrapper, not to the system. Knowing when to make that move is as important as knowing when to use Lambda in the first place.
From our own client work. Measure your specific workload before treating any of this as a guarantee.
| Workload signal | Verdict | Reasoning |
|---|---|---|
| Invocations are short (under 15 min), stateless, and infrequent | Lambda | The runtime's cost and scaling model fits exactly |
| Webhook receiver or event normalizer | Lambda | Short, independent, scales automatically with traffic |
| Scheduled batch job that completes in minutes | Lambda | Eliminates idle compute time vs a persistent cron runner |
| Public API with predictable, steady throughput | Evaluate | Workable with provisioned concurrency; containers may be simpler |
| Inputs larger than a few hundred MB | Containers | Memory ceiling at 10,240 MB; we have hit this on client work |
| Processing that takes longer than 10–12 minutes | Containers | Hard execution limit is 15 minutes; leave headroom for retries |
| Team already runs a container platform in production | Evaluate | Lambda's operational benefit may already be captured; adding it creates a second runtime |
The most common migration destination for Lambda workloads that have grown past the runtime's limits is AWS Fargate, which runs containers without requiring you to manage the underlying EC2 instances. Fargate containers can be sized for the worst-case input rather than the average case, have no execution time limit, and support workloads that need persistent connections and warm in-memory state across requests.
If the team already operates a Kubernetes cluster or an ECS service in production, deploying the migrated workload onto that platform is often simpler than introducing Fargate as a new operational surface. The operational expertise already lives in the existing platform, and the cost of running it has already been paid. Adding Lambda as a second runtime alongside an existing container platform introduces a second deployment model, a second observability story, and a second set of operational procedures without necessarily removing the first.
The vendor lock-in dimension of Lambda is real and worth acknowledging before committing. As we described in our article on software vendor lock-in, every Lambda function sits inside a web of AWS-specific services: IAM, CloudWatch, EventBridge, SQS, API Gateway. A migration is rarely just a code move. The thin-wrapper pattern reduces the code migration cost; the infrastructure migration cost depends on how deeply the surrounding AWS services are wired into the system.
Decision checklist before committing to Lambda
These five questions catch the cases where Lambda would be a poor fit before any code is written. They are not the only questions worth asking, but they are the ones we run through on every new build.
- 01
What is the worst-case input size and processing duration?
If the answer approaches 8–10 GB of memory or 10–12 minutes of execution time, plan for a container from the start.
- 02
What is the expected concurrency at peak load, and how does it compare to the account limit?
Sum the peak concurrency across all functions in the account, compare to the current quota, and request an increase before launch if there is meaningful risk of hitting the ceiling.
- 03
Does the workload need warm in-memory state, persistent connections, or local caches that are expensive to rebuild on every cold start?
Module-scope initialization helps with connections across warm invocations, but cold starts will rebuild them. For connection-heavy workloads, a persistent compute option may be simpler.
- 04
Is the team already operating a container platform in production?
If yes, deploying onto that platform may be operationally simpler than introducing Lambda as a second runtime. Lambda's operational advantage is most significant when there is no existing platform.
- 05
Can the business logic be written as a pure function with typed input and typed output?
If the answer is yes, the thin-wrapper pattern is straightforward to apply. If the logic is deeply entangled with AWS-specific primitives, the portability benefit is harder to realize.
If all five questions produce comfortable answers, then Lambda is likely the right runtime. If any one of them surfaces uncertainty, it is worth addressing that uncertainty before the architecture hardens around a runtime that may need to change.
If you are making this decision for a specific system and want an independent review of the architecture, our software consulting engagements include architecture reviews as a specific service. The Lambda tradeoffs article on our Insights blog covers the same decision framework with two production examples from our client work.
