Serverless in Production: Lambda Patterns That Hold Up
Serverless is a billing model as much as it is an architecture. AWS Lambda charges for execution time in 1-millisecond increments and for the number of invocations, with no charge when the function is idle. That model suits workloads with uneven, bursty, or low-volume traffic better than it suits anything that runs continuously at high throughput — where EC2 or containers become cheaper.
But “Lambda is cheap and scales automatically” is where most teams stop thinking, and it is where most Lambda production problems originate. Cold starts affect user-facing latency more than benchmarks suggest. The concurrency model has hard edges that cause silent request drops. API Gateway adds cost and complexity that HTTP APIs partially address. Event-driven architectures built on SQS and SNS require careful handling of partial failures and poison messages. Step Functions are the right tool for coordinating multi-step workflows, but Standard vs Express Workflow is not an aesthetic choice.
This post covers the mechanics of each, the failure modes, and the patterns that survive contact with production traffic.
The Execution Environment Lifecycle
Understanding cold starts requires understanding how Lambda manages execution environments.
When a function is invoked and no warm execution environment is available, Lambda:
- Allocates a Firecracker microVM with the requested memory
- Downloads and mounts the function package (ZIP or container image)
- Starts the language runtime (Python interpreter, JVM, Node.js, etc.)
- Runs your initialization code (everything outside the handler function)
- Runs your handler
Steps 1–4 are the INIT phase — the cold start. Step 5 is the INVOKE phase — the billable execution. After the handler returns, the execution environment stays warm for an indeterminate period (typically 5–15 minutes of inactivity). The next invocation to hit this environment skips the INIT phase entirely.
Cold invocation timeline:
│◄──────── INIT (cold start) ─────────►│◄── INVOKE ──►│
│ microVM + runtime + init code (100ms–10s) │ your handler │
↑
This is what the user waits for
Warm invocation timeline:
│◄── INVOKE ──►│
│ your handler │
As of August 2025, AWS bills for the INIT phase in addition to the INVOKE phase. For functions with heavy initialization (loading ML models, warming connection pools, importing large libraries), this shift meaningfully increases Lambda spend. Measure your INIT duration in CloudWatch with the Init Duration metric before assuming Lambda is cheap.
Concurrency and scaling
Lambda scales by creating new execution environments in parallel — one environment per concurrent invocation. The default account limit is 1,000 concurrent executions across all functions in a region. A burst of 1,000 simultaneous requests spins up 1,000 execution environments, each going through the INIT phase simultaneously.
Lambda applies a burst limit during rapid scale-up: 3,000 immediate concurrency in us-east-1 and us-west-2 (500 in other regions), then +500 per minute until the account limit is reached. A sudden traffic spike that requires 5,000 concurrent executions in us-east-1 takes at least 4 minutes to fully scale — requests during that window are throttled (429 errors).
Cold Starts: Causes and Solutions
What drives cold start duration
| Factor | Impact | Mitigation |
|---|---|---|
| Runtime | JVM/C# > Python/Node > compiled Go/Rust | Choose runtime appropriate for latency sensitivity |
| Package size | Larger = longer download/mount | Minimize dependencies; use Lambda layers |
| Initialization code | More work = longer INIT | Move slow init outside handler |
| VPC attachment | Was +3–10s; now ~100ms with ENI pooling | No longer the main driver |
| Memory allocation | More memory = faster CPU = faster INIT | Use 1024+ MB for latency-sensitive functions |
The most impactful optimization is almost always moving initialization outside the handler:
|
|
The connection pool is created once per execution environment, not once per invocation. Warm invocations skip the connection overhead entirely.
The same pattern applies to SDK clients, loaded models, configuration files, and anything else that is expensive to initialize:
|
|
SnapStart
SnapStart takes a snapshot of the initialized execution environment and restores it for subsequent invocations, eliminating the runtime startup and initialization time. Originally Java-only, it now supports Java 11+, .NET 8 with Native AOT, and Python in limited preview.
For Java functions, SnapStart reduces cold start time by 80–90%. Enable it per function version:
|
|
SnapStart has one critical constraint: your initialization code must be idempotent with respect to the snapshot/restore cycle. The restored environment has the same state as the snapshot, including any random seeds, timestamps captured at init time, and open sockets. AWS provides CacheUtils hooks for Java to regenerate non-idempotent state at restore time:
|
|
Provisioned concurrency
Provisioned concurrency pre-initializes a specified number of execution environments and keeps them warm permanently. Invocations hitting provisioned environments have no cold start — single-digit millisecond overhead.
|
|
The cost is significant: provisioned concurrency is billed per environment-hour regardless of whether the environments are being used. 10 provisioned concurrent executions at 1,024 MB memory in us-east-1 cost roughly $130/month — in addition to normal invocation charges. Compare that to the cold start frequency and latency impact before enabling it.
When provisioned concurrency is worth it:
- User-facing APIs where P99 latency is a product requirement
- Functions with >500ms cold start times that cannot be reduced by code optimization
- Traffic patterns with predictable peak windows (use Application Auto Scaling to schedule provisioned concurrency up before peak and down afterward)
|
|
Concurrency Controls
Reserved concurrency
Reserved concurrency sets the maximum number of concurrent executions for a specific function, carved out from the account pool. It serves two purposes:
Throttle protection for downstream services: a Lambda processing database writes should not be allowed to open 1,000 simultaneous DB connections. Setting reserved concurrency to 50 caps database connections from that function at 50.
Guaranteed capacity: a reserved concurrency of 100 guarantees that 100 concurrent executions are always available for that function, regardless of what other functions are doing.
|
|
Setting reserved concurrency to 0 effectively disables the function — useful for emergency shutoff without deleting the function.
Throttling behavior
When a function hits its concurrency limit (reserved or account-wide), Lambda throttles — it returns a 429 ThrottlingException. What happens next depends on how the function was invoked:
| Invocation type | Throttle behavior |
|---|---|
| Synchronous (API Gateway, Function URL) | 429 returned immediately to caller |
| Asynchronous (S3 events, SNS) | Lambda retries for up to 6 hours with exponential backoff |
| SQS event source mapping | Message returns to queue; retried per visibility timeout |
| Kinesis/DynamoDB Streams | Shard processing pauses and retries; no data loss |
Synchronous throttles are visible to users as errors. For synchronous invocations, the caller must handle 429s. Asynchronous invocations absorb throttles via retry — but six hours of retry delay is not “absorbed” from a user perspective; it is silent failure with delayed processing.
Event-Driven Patterns
SQS as a buffer
SQS decouples producers from Lambda consumers. The Lambda Event Source Mapping (ESM) polls SQS automatically — you do not write polling code. Lambda scales consumers up to the queue’s available messages.
|
|
Batch processing and partial failures: by default, if your handler raises an exception for any record in a batch, the entire batch goes back to the queue and every record is reprocessed. This causes duplicate processing of records that succeeded before the failure.
Enable partial batch response to report individual record failures:
|
|
Enable this in the ESM configuration:
|
|
Dead letter queues: messages that fail repeatedly (after the queue’s maxReceiveCount) are moved to the DLQ. Set up a DLQ for every SQS queue that drives Lambda processing:
|
|
Alert on DLQ depth — messages in the DLQ represent business logic failures requiring human attention.
SNS fan-out
SNS delivers one event to multiple SQS queues simultaneously. The canonical pattern for broadcasting a domain event to multiple consuming services:
Order placed
│
▼
SNS Topic
(order-events)
│
┌───┴───┐
▼ ▼
SQS SQS
(billing) (inventory)
│ │
▼ ▼
Lambda Lambda
Each consuming service has its own SQS queue with its own DLQ, retry policy, and concurrency settings. A failure in the billing Lambda does not affect the inventory Lambda.
|
|
SNS subscription filter policies limit which message types each queue receives. Without filters, every queue receives every event — as event volume grows, this adds unnecessary processing and cost.
EventBridge for event routing
EventBridge is a more capable event bus than SNS for routing. It supports content-based routing via rule patterns, multiple event sources (AWS services, SaaS providers, your own applications), schema registry, and event replay. Use EventBridge when:
- Events from multiple AWS services need to trigger the same Lambda
- You need content-based routing with complex matching rules
- You want decoupled routing that can be updated without changing producers
|
|
Step Functions
Step Functions orchestrates multi-step workflows where each step is a Lambda function (or any AWS service action). It handles retries, error catching, branching, parallel execution, and wait states — all defined in a state machine, not in application code.
Standard vs Express Workflows
| Feature | Standard | Express |
|---|---|---|
| Maximum duration | 1 year | 5 minutes |
| Execution history | Retained 90 days; queryable | CloudWatch Logs only |
| Pricing | Per state transition ($0.025/1k) | Per execution duration ($0.00001/s) |
| At-least-once | No (exactly-once) | Yes |
| Use case | Long-running, auditable processes | High-volume, short-duration |
Standard Workflows are the default. Express Workflows are cheaper at high volume and appropriate for short-lived pipelines (ETL transformations, event processing pipelines under 5 minutes).
Defining a state machine
|
|
The state machine handles the compensating transaction (ReleaseInventory when payment fails) declaratively. The Lambda functions contain only business logic — no orchestration code, no retry loops, no saga coordination.
Wait for human approval
Standard Workflows support pausing indefinitely pending an external signal:
|
|
The approval email Lambda receives the taskToken. When the approver clicks approve/reject, your API calls:
|
|
The execution resumes from the WaitForApproval state with the response. Without Step Functions, implementing this pattern requires a database to store the token, a cron job to check status, and significant custom code.
API Gateway: REST vs HTTP vs Function URLs
REST API vs HTTP API
Most Lambda-backed APIs should use HTTP API, not REST API. HTTP API is 70% cheaper ($1.00/million vs $3.50/million requests), has lower latency, and supports all common Lambda proxy integration patterns. REST API adds value for specific features: WAF integration, request validation, usage plans, API keys, canary deployments, and x-ray integration at the API layer. If you do not need those features, HTTP API is the correct choice.
|
|
The hidden API Gateway costs
API Gateway’s per-request pricing is only part of the cost:
- Data transfer out: $0.09/GB. An API returning 10 KB responses at 10M requests/month transfers 100 GB — adding ~$9/month on top of request charges.
- CloudWatch Logs: if you enable full execution logging (the default in many setups), API Gateway logs every request body and response. At $0.30/GB ingested, high-volume APIs generate hundreds of GB of logs monthly. Set log level to
ERRORin production and use sampled access logs instead. - Caching: API Gateway’s built-in cache is $0.02–$0.038/hour per cache size tier. A 0.5 GB cache runs ~$14/month — often cheaper than the Lambda invocations it avoids, but easy to forget when estimating costs.
Lambda Function URLs
Function URLs provide a dedicated HTTPS endpoint for a Lambda function without API Gateway. They support IAM authentication or unauthenticated access, streaming responses, and CORS configuration:
|
|
Function URLs have no per-request charge beyond normal Lambda invocation pricing. For simple Lambda-backed APIs without advanced API Gateway features (custom domains, throttling, usage plans), Function URLs eliminate a cost layer entirely. The trade-off: no custom domain (you get a lambda-url.us-east-1.on.aws URL unless you front it with CloudFront), no built-in caching, and no WAF integration.
Packaging: ZIP vs Layers vs Container Images
ZIP packages
The default: a ZIP containing your code and dependencies, up to 50 MB compressed (250 MB uncompressed). Fast to deploy and update.
|
|
Lambda layers
Layers separate shared dependencies from function code. A layer is a ZIP deployed independently and attached to functions at runtime. Multiple functions sharing a layer means the layer counts toward each function’s 250 MB limit, but only deploys once.
|
|
Layers are most useful for: shared internal libraries across multiple functions, large dependencies that change less frequently than the function code (keeps function deploys fast), and binary extensions.
Container images
Container images (up to 10 GB) allow you to use any runtime, any system dependency, and any toolchain. The function code is the container’s CMD or ENTRYPOINT:
|
|
|
|
Container images have longer cold starts than ZIP packages — the image must be pulled and mounted. AWS caches images at the execution environment level so warm invocations are not affected, but the initial cold start after deployment is longer. For image-heavy models (ML inference), the extra startup time is typically worth the flexibility.
Idempotency
Lambda with asynchronous invocation (S3 events, SNS, async invocation API) retries on failure. Step Functions retries Lambda invocations on transient errors. SQS delivers at-least-once. This means your Lambda handlers will be invoked multiple times for the same event in normal production operation. If your handler is not idempotent, you get duplicate side effects — duplicate charges, duplicate emails, duplicate database rows.
The practical approach: generate a deterministic idempotency key from the event, and check/set it in a fast store (DynamoDB with a TTL is the standard choice) at the start of the handler.
AWS Powertools for Lambda provides an idempotency decorator:
|
|
The DynamoDB table stores the idempotency key, the result, and a TTL. Duplicate invocations within the TTL window return the cached result without re-executing the handler body.
Memory Tuning and the CPU Relationship
Lambda does not have a separate CPU configuration. CPU is allocated proportionally to memory — 1,769 MB gets exactly 1 vCPU. Below 1,769 MB, you get a fraction of a vCPU. Above, you get more than one (up to approximately 6 vCPUs at 10,240 MB).
This means increasing memory beyond what your function needs can reduce execution time (and therefore cost) by providing more CPU. The Lambda Power Tuning open-source tool runs your function at multiple memory configurations and plots cost vs. performance:
|
|
The output is a URL to a visualization showing cost and duration at each memory setting. For CPU-bound functions, the optimal cost point is often at 1,769 MB (1 full vCPU) even if the function only uses 400 MB of memory — the extra CPU reduces execution time enough to offset the higher per-ms billing rate.
Observability
Structured logging
|
|
CloudWatch Logs Insights queries structured JSON logs efficiently:
fields @timestamp, event, orderId, @duration
| filter event = "order.processing.failed"
| sort @timestamp desc
| limit 20
Key metrics to monitor
| Metric | Alarm threshold | Meaning |
|---|---|---|
Errors |
> 0 for 1 minute | Handler exceptions |
Throttles |
> 0 | Concurrency limit hit |
ConcurrentExecutions |
> 80% of reserved | Approaching limit |
Duration |
P99 > timeout × 0.8 | Risk of timeout |
InitDuration |
P50 > 1000ms | Cold start optimization needed |
IteratorAge (Kinesis/DDB) |
Growing | Consumer falling behind |
SQS ApproximateAgeOfOldestMessage |
> threshold | Processing backlog |
SQS DLQ ApproximateNumberOfMessagesVisible |
> 0 | Failed messages need attention |
X-Ray tracing
Enable active tracing to get end-to-end traces across Lambda invocations and downstream AWS service calls:
|
|
In your handler, instrument SDK calls:
|
|
X-Ray traces show the full invocation timeline including cold start duration, downstream calls, and where time is actually spent.
Honest Trade-offs
Lambda is not always cheap. The free tier (1M requests/month, 400,000 GB-seconds) covers small workloads. Beyond that, cost depends heavily on invocation volume, duration, and memory. A high-throughput API processing 100M requests/month at 100ms average duration with 512 MB memory costs ~$400/month in Lambda alone — before API Gateway, data transfer, and CloudWatch. An EC2 t3.medium at ~$30/month handles significant throughput. The break-even point is often lower than teams expect.
The INIT phase billing change. As of August 2025, initialization code that was previously “free” is now billed. Functions with heavy initialization (loading ML models, establishing many connections) saw Lambda bill increases of 10–50%. Measure before assuming Lambda is cheap.
Cold starts affect more users than benchmarks suggest. A 1-second cold start on a function with 0.1% cold start rate feels invisible in aggregate metrics but hits 1 in 1,000 users with a bad experience. At P99 cold starts, real-user latency looks fine in P50 dashboards. Instrument InitDuration separately and track it by percentile.
SQS at-least-once is an architectural constraint, not a footnote. Every Lambda that processes SQS messages must be idempotent. Most are not, which means most SQS Lambda pipelines have silent duplicate processing bugs waiting for a deployment failure or concurrency spike to surface them.
Step Functions state machine definition is infrastructure. Changes to the state machine require careful versioning — executions in flight when you deploy a new definition continue running against the old definition. For long-running workflows (days to weeks), this means supporting multiple versions of the state machine simultaneously. Plan for this before building workflows that span business days.
The operational model changes. Lambda removes the concern of server provisioning and patching but adds the concern of function proliferation, invocation model variety (synchronous, asynchronous, stream, event source), and distributed system failure modes. A microservices architecture on Lambda can have 50–100 functions with different concurrency limits, different error handling, and different scaling behaviors. That is a lot of operational surface to understand before something breaks at 2 AM.
Comments