AWS Lambda Cold Starts — Why P99 Spikes to 1.2s at 9 AM
Lambda cold starts added 800-1200ms to our /orders API every morning.
20+ years shipping production infrastructure and CI/CD at scale. Everything here is grounded in real deployments.
- ✓Solid grasp of DevOps fundamentals
- ✓Comfortable with command-line tools
- ✓Basic Linux administration knowledge
- AWS Lambda runs your code on demand without provisioning or managing servers
- Three core components: Functions (your code), Triggers (event sources), Execution Environment (isolated container)
- Cold starts add 100ms–1s latency when a new container spins up
- Performance insight: More memory = more CPU; tuning memory can reduce both cost and duration for compute-heavy tasks
- Production insight: Lambda bills for the full timeout duration even if your function finishes early — always set timeouts realistically
- Biggest mistake: Assuming /tmp is clean between invocations — it persists across warm starts, causing silent data corruption
AWS Lambda is a function-as-a-service (FaaS) compute service that runs your code in response to events without provisioning or managing servers. You upload a function (a zip or container image), configure a trigger (like an API Gateway HTTP request, S3 bucket event, or SQS message), and AWS handles scaling, patching, and availability.
The core trade-off: you pay only for compute time consumed (per-millisecond billing after a 100ms minimum) and get automatic scaling from zero to thousands of concurrent executions — but that elasticity comes with a hidden cost called the cold start. Lambda is ideal for bursty, event-driven workloads (webhooks, image processing, real-time file transforms) but becomes problematic for latency-sensitive, steady-state traffic where the cold start tax dominates P99 response times.
The execution model is deceptively simple: when an event arrives, Lambda spins up a sandbox (a micro-VM using Firecracker), loads your runtime (Node.js, Python, Java, .NET, or custom), runs your handler code, then freezes the sandbox for ~5-15 minutes. Subsequent requests hitting the same sandbox reuse the warm environment — that's a hot start, typically <10ms overhead.
But at 9 AM when traffic spikes, the fleet of warm sandboxes is exhausted, and every new concurrent request forces a cold start: provisioning a new sandbox, downloading your code, initializing the runtime, and executing your init logic. For Java or .NET functions with heavy dependency loading, that cold start can hit 1-2 seconds, while Node.js or Python might stay under 200ms.
The P99 spike you see at 9 AM is the tail latency from a batch of concurrent cold starts hitting users simultaneously.
You control cold start performance through three levers: memory allocation (which proportionally allocates vCPU — 1,769 MB gives one full vCPU), runtime choice (avoid Java/.NET for latency-critical paths), and Provisioned Concurrency (pre-warms a set number of sandboxes, billed per hour even when idle). Provisioned Concurrency eliminates cold starts for predictable traffic patterns but costs roughly the same as keeping EC2 instances running — it's a hedge against the cold start tax, not a free lunch.
For most serverless architectures, the pragmatic approach is to tune memory to the point where your function's compute time plateaus (usually 1-2 GB for I/O-bound work), use async invocation or SQS buffering to absorb cold start latency, and reserve Provisioned Concurrency only for the top 5% of your traffic that drives P99. The alternative — running a container on ECS Fargate or a fixed pool of EC2 instances — gives you predictable sub-10ms latency but requires capacity planning and pays for idle time.
Imagine you own a pizza shop but you only pay the chef when someone actually orders a pizza. The chef doesn't sit around waiting — they appear the moment an order comes in, make the pizza, then disappear. AWS Lambda is exactly that chef. You write a function, AWS runs it only when something triggers it, and you pay only for the milliseconds it runs. No server to babysit, no idle hours billed, no infrastructure to patch.
| Chrome | Firefox | Safari | Edge |
|---|---|---|---|
| ✓ | ✓ | ✓ | ✓ |
Every application needs compute power — something has to run your code. Traditionally, that meant renting a virtual machine or physical server that runs 24/7, even at 3 a.m. when zero users are online. You're paying for potential, not actual work. As cloud adoption exploded, this idle-cost problem became impossible to ignore, especially for startups and teams with unpredictable traffic spikes.
AWS Lambda, launched in 2014, flipped the model. Instead of managing servers, you upload a function — a single, focused piece of logic — and AWS handles everything else: provisioning, scaling, patching, and availability. The term 'serverless' doesn't mean there are no servers; it means YOU don't manage them. The servers exist, they're just Amazon's problem. This lets your team focus entirely on business logic instead of infrastructure operations.
By the end of this article you'll understand how Lambda executes code, how to wire it to real-world triggers like API Gateway and S3, how to avoid the cold start trap that kills performance, and how to structure a production-worthy serverless workflow. You'll also know exactly when Lambda is the right tool — and when it absolutely isn't.
AWS Lambda Serverless — The Execution Model That Bites at Scale
AWS Lambda is a function-as-a-service (FaaS) platform that runs your code in ephemeral, stateless containers. You upload a function, specify a trigger (API Gateway, SQS, S3, etc.), and AWS manages the underlying compute. The core mechanic: each invocation runs in a fresh or recycled sandbox, with no persistent local state across invocations. This is not a long-running process — it's a request-scoped execution that starts, runs, and dies within minutes.
When a Lambda function is invoked, the service either reuses a warm sandbox (if one is available) or creates a new one — this is the cold start. A cold start includes downloading your code, initializing the runtime (JVM in your case), and running your static initializers. For Java, this adds 500ms–1.2s of latency before your handler even executes. The sandbox lifecycle is opaque: you cannot pin a container, and AWS recycles them aggressively (typically after 5–15 minutes of idle time).
Use Lambda when you need elastic scaling with zero idle cost — bursty workloads, event-driven pipelines, or microservices that can tolerate sub-second startup latency. It's not for latency-sensitive user-facing endpoints at the 99th percentile unless you pre-warm or use Provisioned Concurrency. In production, the 9 AM spike is a classic pattern: a wave of concurrent requests hits cold containers simultaneously, amplifying P99 latency by 3–10x.
How AWS Lambda Actually Executes Your Code — The Execution Model
Lambda's execution model is the foundation everything else builds on. When a trigger fires — say, an HTTP request hits API Gateway — Lambda needs to run your function. If a pre-warmed container exists from a recent invocation, Lambda reuses it. This is a 'warm start' and it's fast. If no container is available, Lambda has to bootstrap one from scratch: download your code package, spin up a runtime environment, run any initialisation code outside your handler, then finally invoke your handler. That bootstrap phase is the dreaded cold start.
Cold starts typically add 100ms–1000ms of latency depending on the runtime (.NET and Java are heavier; Node.js and Python are lighter). For a background job this is irrelevant. For a user-facing API call, it's noticeable.
Your handler function receives two objects: the event (the payload that triggered the invocation — could be an HTTP body, an S3 event, a queue message) and the context (metadata about the invocation itself — function name, memory limit, request ID). Understanding this distinction is critical: the event is about WHAT happened, the context is about WHO is running.
Code outside the handler runs once per container lifecycle. That's where you put database connections, SDK clients, and config loading — doing it inside the handler means re-initialising on every single invocation, which is both slow and wasteful.
import boto3 import json import os from PIL import Image import io # ✅ Initialise the S3 client OUTSIDE the handler. # This runs once when the container boots (cold start), # then gets reused across all warm invocations — saving ~50ms per call. s3_client = boto3.client('s3') # Target width for all resized thumbnails THUMBNAIL_WIDTH = 200 def handler(event, context): """ Triggered by an S3 PUT event whenever a new image is uploaded to the 'uploads-raw' bucket. Resizes it and saves a thumbnail to the 'uploads-thumbnails' bucket. """ # The event payload from S3 contains a list of records — # each record represents one file upload. for record in event['Records']: source_bucket = record['s3']['bucket']['name'] object_key = record['s3']['object']['key'] # e.g. 'photos/sunset.jpg' print(f"Processing: s3://{source_bucket}/{object_key}") # Download the original image bytes into memory (no temp file needed) response = s3_client.get_object(Bucket=source_bucket, Key=object_key) image_bytes = response['Body'].read() # Open image with Pillow and calculate proportional height original_img = Image.open(io.BytesIO(image_bytes)) original_w, original_h = original_img.size ratio = THUMBNAIL_WIDTH / original_w new_height = int(original_h * ratio) thumbnail = original_img.resize((THUMBNAIL_WIDTH, new_height)) # Save resized image to an in-memory buffer — Lambda has no persistent disk output_buffer = io.BytesIO() thumbnail.save(output_buffer, format='JPEG', quality=85) output_buffer.seek(0) # Rewind buffer to the start before uploading # Write thumbnail to the destination bucket under the same key name destination_bucket = os.environ['THUMBNAIL_BUCKET'] # Read from env vars, not hardcoded s3_client.put_object( Bucket = destination_bucket, Key = object_key, Body = output_buffer, ContentType = 'image/jpeg' ) print(f"Thumbnail saved: s3://{destination_bucket}/{object_key} ({THUMBNAIL_WIDTH}x{new_height})") # Lambda expects a return value when invoked synchronously (e.g. via API Gateway). # For async triggers like S3, the return value is ignored — but it's good practice. return { 'statusCode': 200, 'body': json.dumps({'processed': len(event['Records'])}) }
Init Duration field in CloudWatch logs to measure it.Wiring Lambda to the Real World — Triggers, Events, and API Gateway
A Lambda function sitting alone does nothing. It needs a trigger — an AWS service that says 'hey, something happened, go run'. The trigger determines the shape of the event object your handler receives, which is why reading the AWS event schema docs for each trigger type matters.
The most common triggers in production are: API Gateway (HTTP requests), S3 (file uploads/deletions), SQS (queue messages for async processing), EventBridge (scheduled cron jobs and event routing), DynamoDB Streams (react to database changes), and SNS (fan-out notifications).
API Gateway is the one you'll use for building REST APIs or webhooks. When a request hits your endpoint, API Gateway wraps it into a structured event object and hands it to Lambda. Your function returns a response object with a statusCode, headers, and body, and API Gateway translates that back into a real HTTP response.
The Lambda Proxy Integration model (the default and recommended approach) passes the raw request to your function and expects you to construct the full HTTP response yourself. This gives you complete control over status codes, CORS headers, and response bodies. Older tutorials show Lambda custom integrations — avoid them, they're fiddly and add complexity for no gain.
For async workloads, SQS is your best friend. Rather than calling Lambda directly (which creates tight coupling), push messages to a queue and let Lambda poll and process them in batches. This naturally handles traffic bursts without rate-limit errors.
import json import boto3 import uuid import os from datetime import datetime, timezone # DynamoDB resource initialised at cold-start — reused on warm invocations dynamodb = boto3.resource('dynamodb') orders_table = dynamodb.Table(os.environ['ORDERS_TABLE_NAME']) def handler(event, context): """ Handles POST /orders from API Gateway (Lambda Proxy Integration). Creates a new order record in DynamoDB and returns the order ID. API Gateway event shape (key fields): event['httpMethod'] -> 'POST' event['path'] -> '/orders' event['body'] -> Raw JSON string of the request body event['requestContext'] -> Metadata including caller identity """ http_method = event.get('httpMethod', '') # Route guard — this function only handles order creation if http_method != 'POST': return _build_response(405, {'error': f'Method {http_method} not allowed'}) # API Gateway sends the body as a raw string — we must parse it try: request_body = json.loads(event.get('body') or '{}') except json.JSONDecodeError: return _build_response(400, {'error': 'Request body must be valid JSON'}) # Validate required fields before touching the database required_fields = ['customer_id', 'items', 'total_amount'] missing_fields = [f for f in required_fields if f not in request_body] if missing_fields: return _build_response(400, {'error': f'Missing required fields: {missing_fields}'}) # Build the order record order_id = str(uuid.uuid4()) # Unique ID for this order created_at = datetime.now(timezone.utc).isoformat() # ISO 8601, always UTC order_record = { 'order_id': order_id, 'customer_id': request_body['customer_id'], 'items': request_body['items'], 'total_amount': str(request_body['total_amount']), # DynamoDB doesn't support float natively 'status': 'PENDING', 'created_at': created_at } # Write to DynamoDB — put_item overwrites if the key already exists, # so ConditionExpression ensures we never silently stomp an existing order orders_table.put_item( Item=order_record, ConditionExpression='attribute_not_exists(order_id)' ) print(f"Order created: {order_id} for customer {request_body['customer_id']}") return _build_response(201, { 'order_id': order_id, 'status': 'PENDING', 'created_at': created_at }) def _build_response(status_code, body_dict): """ Constructs the response object API Gateway expects. CORS headers are included so browser-based clients can call this API. Without these headers, browsers silently block the response. """ return { 'statusCode': status_code, 'headers': { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' # Tighten to your domain in production }, 'body': json.dumps(body_dict) }
Lambda Event Source Reference Table — What Triggers Your Function
The following table catalogs the most common Lambda event sources, their invocation model, payload size limits, retry behavior, and best-fit use cases. Knowing these details helps you design reliable, cost-efficient serverless workflows. For each source, the event structure is fixed by AWS — you cannot change the schema — so you must parse the documented fields correctly in your handler.
| Event Source | Invocation Type | Max Payload | Retry Behavior | Best For |
|---|---|---|---|---|
| API Gateway | Synchronous | 10 MB (request), 10 MB (response) | No automatic retries; client handles | HTTP/REST APIs, webhooks |
| S3 (Event Notifications) | Asynchronous | 128 KB (event record) | 2 retries (async) | File processing (image resize, logs, analytics) |
| DynamoDB Streams | Stream-based | 1 MB (batch) | Indefinite retry until data expires (24h) | React to DB changes (materialized views, sync) |
| Kinesis Data Streams | Stream-based | 1 MB (per record) | Indefinite retry until data expires (7 days) | Real-time data processing (clickstreams, logs) |
| SQS (Standard) | Poll-based (event source mapping) | 256 KB per message | Retries based on redrive policy | Async decoupling, buffering, batch processing |
| SQS (FIFO) | Poll-based (event source mapping) | 256 KB per message | Retries with exactly-once semantics | Ordered processing, deduplication |
| SNS (topic subscription) | Asynchronous | 256 KB | 2 retries (async) | Fan-out notifications to multiple subscribers |
| EventBridge (scheduled or event) | Asynchronous | 256 KB | 2 retries (async) | Cron jobs, event routing between AWS services |
| CloudFront (Lambda@Edge) | Synchronous | 1 MB | No automatic retries | Modify HTTP request/response at edge |
| Lambda Function URL | Synchronous | 10 MB (request/response) | No automatic retries | Simple HTTP endpoints without API Gateway |
Key details to remember: - Asynchronous invocations (S3, SNS, EventBridge) retry twice with 1–2 minute delays. Always configure a dead-letter queue (DLQ) for these triggers. - Stream-based triggers (DynamoDB, Kinesis) retry until the data record expires — a persistent bug will block the entire shard. Use bisectBatchOnFunctionError to split batches on failure. - Synchronous triggers (API Gateway, Lambda Function URL) do not retry; your client or upstream service must implement retry logic. - Payload size limits are hard: if your S3 event payload exceeds 128 KB, S3 will send the notification anyway but truncates the event — use the Deep Archive storage class sparingly to avoid this.
For a full list of event sources and their exact event schemas, refer to the [AWS Lambda Developer Guide — Using AWS Lambda with other services](https://docs.aws.amazon.com/lambda/latest/dg/lambda-services.html).
Cold Starts, Memory Tuning, and the Performance Levers You Actually Control
Lambda gives you one direct performance dial: memory. You set it anywhere from 128 MB to 10,240 MB. What most developers don't realise is that CPU allocation scales proportionally with memory. A 1,024 MB Lambda function gets roughly 8x the CPU of a 128 MB one. If your function is CPU-bound (image processing, data transformation, encryption), doubling the memory can halve the execution time — and since you pay for duration × memory, the cost often stays the same or even drops.
Cold starts are the other major lever. Three strategies exist: Provisioned Concurrency, keeping functions warm with scheduled EventBridge pings, and minimising package size.
Provisioned Concurrency is the only AWS-supported solution. You pay for a set number of pre-warmed containers to stay alive at all times. It costs more than on-demand but eliminates cold starts entirely for that concurrency slot. Use it for customer-facing APIs where tail latency matters.
Package size matters because Lambda has to download your deployment package before running it. A 50 MB Python package with unnecessary dependencies cold-starts noticeably slower than a 3 MB lean package. Use Lambda Layers to separate large dependencies (like numpy or Pillow) from your application code, and use .zip deployment packages rather than container images unless you specifically need Docker tooling.
Finally, watch your timeout setting. The default is 3 seconds. Downstream API calls, DB queries, and S3 operations can easily exceed this. Set it realistically (15 minutes max) and always handle partial failures gracefully.
# AWS SAM (Serverless Application Model) template — the standard way to # define Lambda functions as Infrastructure-as-Code. # Run: sam build && sam deploy --guided AWSTemplateFormatVersion: '2010-09-09' Transform: AWS::Serverless-2016-10-31 Description: Orders API — production-grade Lambda configuration Globals: Function: # Runtime for all functions in this template unless overridden Runtime: python3.12 # Timeout generous enough for DynamoDB + downstream calls, not infinite Timeout: 30 # Environment variables available to all functions Environment: Variables: LOG_LEVEL: INFO ORDERS_TABLE_NAME: !Ref OrdersTable Resources: OrdersApiFunction: Type: AWS::Serverless::Function Properties: FunctionName: orders-api-handler CodeUri: src/ Handler: orders_api_handler.handler # 512 MB gives ~4x CPU vs 128 MB — worth it for JSON parsing + DynamoDB calls # Run AWS Lambda Power Tuning tool to find YOUR optimal memory setting MemorySize: 512 # Provisioned concurrency: 5 containers always warm for the production alias # This eliminates cold starts for the first 5 concurrent requests # Cost: ~$0.0000041 per GB-second × 5 containers × all hours in month AutoPublishAlias: live ProvisionedConcurrencyConfig: ProvisionedConcurrentExecutions: 5 # IAM permissions — principle of least privilege # Only grant what this specific function actually needs Policies: - DynamoDBCrudPolicy: TableName: !Ref OrdersTable # API Gateway trigger — Lambda Proxy Integration (recommended) Events: CreateOrder: Type: Api Properties: Path: /orders Method: POST # OPTIONS method needed for browser CORS preflight requests CreateOrderOptions: Type: Api Properties: Path: /orders Method: OPTIONS OrdersTable: Type: AWS::DynamoDB::Table Properties: TableName: orders BillingMode: PAY_PER_REQUEST # Serverless billing — no provisioned capacity to manage AttributeDefinitions: - AttributeName: order_id AttributeType: S KeySchema: - AttributeName: order_id KeyType: HASH # Point-in-time recovery — always enable for production data PointInTimeRecoverySpecification: PointInTimeRecoveryEnabled: true Outputs: OrdersApiEndpoint: Description: "API Gateway endpoint for the Orders API" Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/orders"
Provisioned Concurrency vs Cold Start — Visual Breakdown
Provisioned Concurrency is the only AWS-native mechanism that guarantees zero cold starts for a fixed number of concurrent invocations. The diagram below contrasts the request flow for an on-demand function (which may incur a cold start) versus a function with Provisioned Concurrency.
How it works: When you enable Provisioned Concurrency, Lambda pre-initialises a specified number of execution environments and keeps them warm. Incoming invocations are routed to these warm environments instantly. On-demand environments are still used for invocations beyond the provisioned count, so cold starts still occur when the provisioned pool is exhausted. The visual logic flow:
- On-Demand Path: Request arrives → check for warm container → if none found → cold start (init + handler delay).
- Provisioned Concurrency Path: Request arrives → route to pre-warmed container → warm start (handler only, no init delay).
The benefit is a 100% elimination of cold start latency for the initial set of concurrent requests. The cost is paying for those environments even when idle.
When to use it: Only for latency-critical production endpoints where p99 must stay below, say, 500ms. For batch processing or background jobs, on-demand is sufficient and cheaper.
When NOT to use it: If your function is rarely invoked (once per hour), the cost of keeping a container warm 24/7 will far exceed any performance benefit. A simple scheduled EventBridge ping (every 5 minutes) is cheaper and nearly as effective — though not guaranteed, as AWS may reclaim containers during maintenance.
Alternative warming patterns: A common pattern is to set up an EventBridge rule that invokes your function every 5 minutes with a synthetic event (e.g., a 'warmup' field). This keeps 1–2 containers warm without Provisioned Concurrency cost. However, this is unreliable under burst traffic — if multiple concurrent requests arrive simultaneously, only one container may be warm. Provisioned Concurrency guarantees capacity.
# Snippet: enabling Provisioned Concurrency in SAM # Full template in previous section; this is the critical part AutoPublishAlias: live ProvisionedConcurrencyConfig: ProvisionedConcurrentExecutions: 5 # After deploy, verify with: # aws lambda get-provisioned-concurrency-config --function-name orders-api-handler --qualifier live # Expected: Status: READY, AllocatedProvisionedConcurrentExecutions: 5
ProvisionedConcurrencySpillover metric to see how many requests exceed the provisioned pool.Lambda Resource Limits & Constraints Table — What You Can't Change
Lambda has specific hard limits that constrain how you design your serverless applications. Exceeding these limits results in deployment failures, throttling, or runtime errors. The table below shows the most important limits — know them before you architect your system.
| Resource | Limit | Notes |
|---|---|---|
| Memory per function | 128 MB – 10,240 MB (in 1 MB increments) | CPU scales with memory; more memory = more CPU |
Ephemeral storage /tmp | 512 MB | Shared across warm invocations; not reset on reuse |
| Maximum execution timeout | 15 minutes (900 seconds) | Hard limit; cannot be increased |
| Deployment package size (.zip) | 250 MB (unzipped), 50 MB (zipped for direct upload) | Use Lambda Layers to exceed: up to 5 layers, each up to 250 MB unzipped |
| Container image size | 10 GB (ECR image) | Larger images cause slower cold starts |
| Concurrent executions per region (default) | 1,000 | Can be increased via service quota request |
| Concurrent executions per function (default) | 1,000 (unreserved) | Can be limited with reserved concurrency |
| Request/response payload size (sync) | 256 KB (6 MB for API Gateway) | For larger payloads, use S3 or streaming |
| Function environment variables | 4 KB total (unencrypted) | Use AWS Secrets Manager or Parameter Store for secrets |
| Lambda Layers per function | 5 | Layer size counts toward total unzipped limit (250 MB) |
| Event source mappings per function | 10 (for SQS, DynamoDB, Kinesis) | Add more by using multiple triggers |
| Reserved concurrency per function | 0 – regional limit | Setting reserved concurrency guarantees capacity but blocks other functions |
| Provisioned Concurrency per function | 0 – regional limit | Regional limit is 5,000 per region by default |
| Function execution role | AWS IAM role | Lambda attaches this role to the execution environment |
How to work around limits: - Package size: If you exceed 250 MB unzipped, separate large libraries (Panda, OpenCV, etc.) into Lambda Layers. Each layer can be up to 250 MB, and you can use up to 5 layers, giving you an effective 1.25 GB total. - Timeout: Lambda supports up to 15 minutes. For longer jobs, use AWS Step Functions to orchestrate multiple Lambda calls, or switch to Fargate/Batch. - Concurrency: If you anticipate more than 1,000 concurrent executions, request a limit increase in the AWS Service Quotas console. Also consider using SQS buffering to smooth traffic. - Payload size: For payloads larger than 256 KB, upload to S3 and pass the object key in the event. Lambda reads from S3 instead of the event body.
These limits are not negotiable — building against them from day one avoids costly refactors later.
Production Patterns: Error Handling, Retries, and Observability
Lambda's default retry behaviour depends on invocation type. Synchronous invocations (API Gateway, custom apps) do NOT retry automatically — your client must handle errors. Asynchronous invocations (S3, SNS, EventBridge) retry twice using built-in retry logic, then discard the event unless you configure a dead-letter queue (DLQ). Stream-based triggers (DynamoDB Streams, Kinesis) retry until the data expires (default 24 hours) and block the shard — meaning a permanently failing function stalls your stream.
For synchronous APIs, implement your own retry with exponential backoff inside Lambda. For async triggers, always attach a DLQ (SQS or SNS) to capture failed events. Without a DLQ, failed events vanish after two retries — you'll never know.
Observability in Lambda is driven by CloudWatch Logs, CloudWatch Metrics, and AWS X-Ray. Every invocation writes a REPORT line showing duration, billed duration, memory used, and init duration. X-Ray traces show downstream calls to DynamoDB, S3, and other services — essential for debugging latency.
Structured logging is critical. Use JSON-formatted logs with a correlation ID (often the X-Ray trace ID) so you can correlate invocations. Avoid print() statements without context.
import json import logging import os import traceback # Configure structured JSON logging logging.basicConfig(level=logging.INFO, format='%(message)s') logger = logging.getLogger() def handler(event, context): # Capture X-Ray trace ID for correlation trace_id = context.aws_request_id logger.info(json.dumps({ 'trace_id': trace_id, 'event_type': type(event).__name__, 'message': 'Function invoked' })) try: # business logic result = process_order(event) return _build_response(200, result) except ValidationError as e: logger.warning(json.dumps({ 'trace_id': trace_id, 'error': str(e), 'message': 'Validation failed' })) return _build_response(400, {'error': str(e)}) except ExternalServiceError as e: logger.error(json.dumps({ 'trace_id': trace_id, 'error': str(e), 'message': 'Downstream service failed' })) # Retry with exponential backoff (simplified) time.sleep(2 ** context.retry_attempt) # not recommended for sync; use async DLQ instead raise # Let Lambda retry if async except Exception: logger.critical(json.dumps({ 'trace_id': trace_id, 'error': traceback.format_exc(), 'message': 'Unhandled exception' })) return _build_response(500, {'error': 'Internal server error'}) def _build_response(status_code, body): return { 'statusCode': status_code, 'headers': {'Content-Type': 'application/json'}, 'body': json.dumps(body) }
- Synchronous invocations: no automatic retries. The caller must handle errors.
- Asynchronous invocations: two automatic retries with exponential backoff (0, 1, 2 min delays).
- Stream-based triggers: retry forever (up to 24 hours or 7 days for Kinesis).
- Always configure a dead-letter queue (DLQ) for async triggers to catch failures.
- DLQ can be an SQS queue (for processing later) or an SNS topic (for alerting).
When Lambda is the Wrong Tool — Alternatives and Trade-offs
Lambda excels at short-lived, event-driven, bursty workloads. But it's not a general-purpose compute platform. If your workload contradicts any of the following, reach for another service.
First, long-running processes: Lambda's hard 15-minute timeout means you cannot run a nightly batch job that takes an hour. Use AWS Batch or ECS/Fargate for that.
Second, stateful applications: Lambda is stateless by design. If your application needs to hold client connections (WebSockets), maintain session state in memory, or use files that persist beyond a single invocation, you'll fight the architecture. Use EC2 or ECS with sticky sessions instead.
Third, predictable, steady traffic: If your load is constant 24/7, Lambda's per-ms billing is more expensive than a low-cost EC2 instance or a reserved instance. A t3.small running 24 hours costs $15/month; 5 million Lambda invocations at 200ms average could cost $8, but steady traffic at 100 req/s would push cost higher than an EC2.
Fourth, heavy GPU/compute: Lambda has no GPU support. ML training, 3D rendering, or video transcoding with high compute needs are better on EC2 GPU instances or SageMaker.
Fifth, very low latency requirements (<10ms): Lambda's cold start and network overhead make it unsuitable for sub-millisecond use cases like real-time trading. Use containers on EC2 or custom hardware.
Finally, large binary processing: Lambda's deployment package limit is 250 MB (unzipped) and 50 MB (zipped) for direct upload. If you're processing multi-GB files, you'll hit storage and timeout limits. Use ECS or Batch with EFS.
# Quick decision reference for choosing Lambda or an alternative deployment_type: - name: Lambda good_for: - Event-driven functions - Spiky HTTP APIs - Scheduled tasks under 15 minutes bad_for: - Long-running batch jobs (over 15 min) - Stateful applications - Predictable steady traffic - name: ECS/Fargate good_for: - Containerized workloads (stateful or stateless) - Long-running services - WebSocket servers - Background workers needing >15 min bad_for: - Very short-lived functions (cold start is higher) - Simple event reactions (overkill) - name: EC2 good_for: - Full control of OS/GPU - Stable traffic patterns - Applications needing persistent storage bad_for: - Variable traffic (idle cost) - Need minimal ops overhead # Cost comparison example: # Scenario: 100 req/s steady, average duration 100ms, 512 MB memory # Lambda: 100 * 86400 = 8.64M invocations/day -> $1.728/day = $51.84/month # t3.small (2 vCPU, 2 GB) on-demand: ~$15/month (24/7) # Lambda is 3x more expensive for steady traffic. # Lambda wins when traffic is spiky and idle periods exist.
The Core Concepts: Serverless & Event-Driven — What Your Manager Actually Means
Your manager says 'serverless'. You hear 'no ops work'. Both are wrong.
Serverless doesn't mean servers vanish. It means you stop caring about kernel patches, SSH keys, and OS upgrades. AWS runs the hypervisor, the runtime, and the scaling plane. Your job shrinks to code and IAM permissions. That's the trade: you give up control over the execution environment in exchange for not paging at 3 AM when a disk fills up.
Event-driven is the engine behind that trade. Your Lambda function does nothing until something pokes it. An S3 upload. An API Gateway request. A DynamoDB stream. That event arrives as a JSON payload, your function processes it, and then it dies. No daemons. No polling loops. No idle costs.
The mental model: Lambda is a stateless worker pool that only exists while handling a single request. If you write code that assumes long-lived connections, local file state, or sticky sessions, you will fail in production. Design for stateless idempotent handlers or don't deploy it.
// io.thecodeforge — devops tutorial // Minimal event-driven pipeline: S3 → Lambda → DynamoDB AWSTemplateFormatVersion: '2010-09-09' Resources: ImageProcessorFunction: Type: AWS::Serverless::Function Properties: CodeUri: ./handler.py Runtime: python3.12 Events: S3UploadEvent: Type: S3 Properties: Bucket: !Ref InputImagesBucket Events: s3:ObjectCreated:* Policies: - DynamoDBCrudPolicy: TableName: !Ref MetadataTable InputImagesBucket: Type: AWS::S3::Bucket MetadataTable: Type: AWS::DynamoDB::Table Properties: AttributeDefinitions: - AttributeName: image_id AttributeType: S KeySchema: - AttributeName: image_id KeyType: HASH BillingMode: PAY_PER_REQUEST
Use Cases That Won't Burn Your Budget — And Two That Will
Lambda shines when the work is asynchronous, bursty, or short-lived. It bleeds money when you try to force it into a container-shaped hole.
- Image/video processing on upload. S3 event triggers Lambda, you resize, transcode, or extract metadata. Perfect fit: milliseconds of CPU per file, scales to zero when no uploads happen.
- Webhook handlers. Stripe, GitHub, Slack — they send JSON, you validate a signature, update a database, return 200. No keepalive costs.
- Scheduled batch jobs. CloudWatch Events every 15 minutes to purge stale records or aggregate metrics. 900 invocations a day, 500ms each, costs pennies.
- Real-time file transformation. CSV → Parquet before loading into Athena. Lambda grabs the S3 object, transforms in memory, writes to a target bucket.
- Synchronous request-response APIs with tight latency SLAs (<100ms p99). Cold starts kill you. Yes, Provisioned Concurrency exists. Yes, it costs 3x more per GB-hour than warm Lambda.
- Long-running data processing (>15 minutes runtime). Lambda hard caps at 15 minutes. If your ETL job runs 20 minutes, you can't split it? Lambda is the wrong tool. Use EMR or Fargate.
- WebSocket connections with 10k+ concurrent users. Lambda per-connection costs scale linearly with active connections. A single t3.medium handling WebSockets costs less at scale.
// io.thecodeforge — devops tutorial // Lambda scheduled to purge expired session tokens every 15 minutes AWSTemplateFormatVersion: '2010-09-09' Resources: SessionCleanupFunction: Type: AWS::Serverless::Function Properties: CodeUri: ./cleanup.py Runtime: python3.12 Events: ScheduledPurge: Type: Schedule Properties: Schedule: rate(15 minutes) Policies: - DynamoDBCrudPolicy: TableName: !Ref SessionTable Timeout: 120 SessionTable: Type: AWS::DynamoDB::Table Properties: AttributeDefinitions: - AttributeName: session_id AttributeType: S - AttributeName: expires_at AttributeType: N KeySchema: - AttributeName: session_id KeyType: HASH BillingMode: PAY_PER_REQUEST TTLSpecifications: AttributeName: expires_at Enabled: true
💰 Pricing: Pay-Per-Use — The Bill That Sneaks Up on You
Lambda pricing sounds simple: pay per request and compute duration. But the details matter when your traffic goes from zero to a million requests overnight.
Requests cost $0.20 per million. Compute charges by GB-second — memory allocation times execution time. The cheaper your memory tier, the longer your function runs, and sometimes a slightly higher memory setting finishes faster and costs less overall. Always benchmark with realistic payloads.
The real budget killer? Free tier ends after 12 months and 1 million requests. After that, sustained traffic adds up fast. A 128MB function running 500ms, hit 10 million times per month, runs roughly $35 — peanuts. But a 3GB function with 30-second cold starts and retries? That bill hits $500+ real quick.
Watch for data transfer costs too. Lambda talking to RDS or S3 in different regions racks up per-GB charges. Your serverless bill isn't just Lambda — it's the entire egress chain.
// io.thecodeforge — devops tutorial // Monthly cost estimate for 128MB, 500ms avg duration, 10M requests pricing: region: us-east-1 requests: count: 10_000_000 cost_per_million: 0.20 subtotal: 2.00 compute: memory_gb: 0.125 duration_seconds: 0.5 gb_seconds_per_request: 0.0625 total_gb_seconds: 625_000 free_tier_gb_seconds: 400_000 billable_gb_seconds: 225_000 cost_per_gb_second: 0.0000166667 subtotal: 3.75 total_monthly: 5.75
⚙️ Key Features — What Makes Lambda Worth the Headache
Lambda exists because nobody wants to manage servers. The core feature is automatic scaling: zero to thousands of concurrent executions in seconds, no provisioning, no load balancers. Each request gets an isolated micro-VM — your code and dependencies, no neighbors.
Event-driven execution is the architectural win. Lambda sits downstream of 200+ AWS services as a native event target. S3 object creation, DynamoDB streams, SNS, SQS, API Gateway — just drop a Lambda in the flow and you're done. No polling, no workers, no crons.
Built-in observability through CloudWatch logs, metrics, and traces. Every invocation gets a request ID, duration, memory used, and billing breakdown. You can catch failures, retry with backoff, and DLQ dead letters to SQS or SNS for reprocessing.
But don't mistake simplicity for power. Lambda is stateless by design — you cannot store local state across invocations. Any state must live in external services. That's a feature, not a bug — it forces stateless architecture that scales horizontally without thinking.
// io.thecodeforge — devops tutorial features: scaling: auto, concurrency up to 1000 per region (default) runtime: Node.js, Python, Java, Go, .NET, Ruby, custom runtime triggers: S3, DynamoDB, Kinesis, SQS, SNS, API Gateway, EventBridge max_execution: 15 minutes per invocation ephemeral_storage: 512 MB /tmp directory per execution network: VPC support via ENI attachments logging: CloudWatch Logs, X-Ray traces, metrics security: IAM roles per function, no shared credentials
Securing Your Account with IAM
Identity and Access Management (IAM) is the front door to your AWS account. Before writing a single Lambda function, you must understand why IAM matters: it prevents accidental data leaks, stops unauthorized cost spikes, and enforces least-privilege access. The principle is simple—every action your Lambda performs, from reading an S3 object to writing logs in CloudWatch, requires explicit permission. Start by creating dedicated IAM roles for each function rather than using a shared admin role. Attach AWS managed policies like AWSLambdaBasicExecutionRole initially, then scope down to custom inline policies that specify exact ARNs of resources your function touches. Use IAM Access Analyzer to validate your policy statements against actual usage. Avoid hardcoding credentials in environment variables; rely on the execution role's temporary credentials. Enable CloudTrail to audit all IAM actions, and rotate keys regularly for any human users. This discipline prevents the all-too-common production incident where a misconfigured policy exposes a database. Treat IAM as your first security line, not an afterthought.
// io.thecodeforge — devops tutorial // IAM role for Lambda with least privilege Resources: LambdaExecutionRole: Type: AWS::IAM::Role Properties: AssumeRolePolicyDocument: Version: '2012-10-17' Statement: - Effect: Allow Principal: Service: lambda.amazonaws.com Action: sts:AssumeRole Policies: - PolicyName: WriteLogs PolicyDocument: Version: '2012-10-17' Statement: - Effect: Allow Action: logs:CreateLogGroup Resource: !Sub arn:aws:logs:${AWS::Region}:${AWS::AccountId}:* - Effect: Allow Action: - logs:CreateLogStream - logs:PutLogEvents Resource: !Sub arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/*:* - PolicyName: ReadS3Data PolicyDocument: Version: '2012-10-17' Statement: - Effect: Allow Action: s3:GetObject Resource: !Sub arn:aws:s3:::my-secure-bucket/*
Computing in AWS
Lambda is one compute option among many, and choosing it blindly leads to cost overruns or performance headaches. Understanding why is straightforward: compute models in AWS fall on a spectrum of control versus overhead. EC2 gives you full control over the OS, runtime, and scaling but requires managing patching, capacity planning, and failover. ECS/EKS removes the underlying server management while letting you control the container orchestration. Fargate abstracts away the infrastructure entirely but still gives you per-task pricing and no cold starts. Lambda sits at the extreme end—zero infrastructure management, automatic scaling from zero to thousands of concurrent executions, but with hard limits on execution time (15 minutes max), memory (10,240 MB), and storage (512 MB /tmp). The why: If your workload is bursty, event-driven, and short-lived, Lambda is ideal. If you need persistent connections, long-running processes, or predictable latency for user-facing APIs under load, consider Fargate or EC2 with an Auto Scaling Group. Many teams build a hybrid architecture—Lambda for glue logic and ingestion pipelines, ECS for the heavy-lifting backend.
// io.thecodeforge — devops tutorial // Fargate service for long-running compute Resources: ComputeService: Type: AWS::ECS::Service Properties: ServiceName: heavy-processor TaskDefinition: !Ref ComputeTaskDefinition LaunchType: FARGATE NetworkConfiguration: AwsvpcConfiguration: AssignPublicIp: DISABLED SecurityGroups: - sg-12345 Subnets: - subnet-abcdef DesiredCount: 2 DeploymentConfiguration: MinimumHealthyPercent: 50 MaximumPercent: 200 ComputeTaskDefinition: Type: AWS::ECS::TaskDefinition Properties: Family: compute-td Cpu: 1024 Memory: 2048 ExecutionRoleArn: !GetAtt TaskExecutionRole.Arn ContainerDefinitions: - Name: app-container Image: nginx:alpine Essential: true PortMappings: - ContainerPort: 80 Protocol: tcp
The Cold Start P99 Spike That Killed Our API Response Times
- Measure p50 and p99 separately — if p99 is much higher than p50, cold starts or throttling are the likely cause.
- Use Provisioned Concurrency for latency-sensitive endpoints, but only for the minimum number needed.
- Minimise package size and externalise heavy dependencies to Lambda Layers.
aws lambda get-function-configuration --function-name your-functionaws cloudwatch get-metric-statistics --metric-name InitDuration --namespace AWS/Lambda --dimensions Name=FunctionName,Value=your-functionaws cloudwatch get-metric-statistics --metric-name Throttles --namespace AWS/Lambda --period 300 --statistics Sumaws lambda get-account-settings | grep Concurrencyaws logs filter-log-events --log-group-name /aws/lambda/your-function --filter-pattern 'exit code 137'aws lambda update-function-configuration --function-name your-function --memory-size 1024aws xray get-trace-summaries --start-time <unix> --end-time <unix> --filter 'service("your-function")'aws lambda update-function-configuration --function-name your-function --timeout 30| Aspect | AWS Lambda (Serverless) | EC2 Instance (Traditional) | AWS Fargate (Serverless Containers) |
|---|---|---|---|
| Billing model | Per 1ms of execution + invocation count | Per hour the instance runs (even idle) | Per second of vCPU and memory used |
| Scaling | Automatic — up to 1,000 concurrent by default | Manual or Auto Scaling Group (minutes to scale) | Automatic — per service or task definition |
| Max execution time | 15 minutes per invocation | Unlimited — process runs indefinitely | Unlimited (but services are long-running) |
| Cold start latency | 100ms–1s for first request after idle period | None (process stays resident) | Minimal (container pre-pulled if warm) |
| State management | Stateless — no persistent memory between calls | Stateful — in-memory state survives between requests | Stateful by design (container runs persistently) |
| Long-running workloads | Not suitable (15 min cap) | Ideal — batch jobs, ML training, websockets | Ideal for long-running services and workers |
| Operational overhead | Near zero — AWS patches, scales, monitors | High — OS updates, capacity planning, monitoring setup | Low — no OS patching, but container management needed |
| Best for | Event-driven, spiky, short-duration tasks | Steady, high-throughput, stateful applications | Containerized apps that need to scale and are long-running |
| File | Command / Code | Purpose |
|---|---|---|
| image_resize_handler.py | from PIL import Image | How AWS Lambda Actually Executes Your Code |
| orders_api_handler.py | from datetime import datetime, timezone | Wiring Lambda to the Real World |
| serverless-function-config.yaml | AWSTemplateFormatVersion: '2010-09-09' | Cold Starts, Memory Tuning, and the Performance Levers You A |
| provisioned-concurrency-snippet.yaml | AutoPublishAlias: live | Provisioned Concurrency vs Cold Start |
| error_handling_logging.py | logging.basicConfig(level=logging.INFO, format='%(message)s') | Production Patterns |
| lambda-vs-alternatives.yaml | deployment_type: | When Lambda is the Wrong Tool |
| EventFlow.yml | AWSTemplateFormatVersion: '2010-09-09' | The Core Concepts: Serverless & Event-Driven |
| ScheduledCleanup.yml | AWSTemplateFormatVersion: '2010-09-09' | Use Cases That Won't Burn Your Budget |
| LambdaPricingExample.yml | pricing: | 💰 Pricing: Pay-Per-Use |
| LambdaFeatureChecklist.yml | features: | ⚙️ Key Features |
| iam-lambda-role.yml | Resources: | Securing Your Account with IAM |
| fargate-vs-lambda.yml | Resources: | Computing in AWS |
Key takeaways
Common mistakes to avoid
5 patternsInitialising DB connections inside the handler
Ignoring the 512 MB /tmp storage limit and assuming a clean filesystem
Setting Lambda timeout lower than the slowest downstream dependency
Using synchronous invocation for long-polling or cron tasks
Forgotten DLQ for async triggers
Interview Questions on This Topic
A Lambda function handles user logins and is experiencing high tail latency during morning traffic spikes. The p99 latency is 1.2 seconds but the p50 is 180ms. What's likely causing this and how would you fix it?
Explain the difference between synchronous and asynchronous Lambda invocation models. Give a concrete example of when you'd choose one over the other, and what happens to errors in each model.
Your team wants to use Lambda to process DynamoDB Stream events. A batch of 100 records comes in, your function processes 60 successfully, then fails on record 61. What happens to all 100 records, and how would you implement partial batch failure handling to avoid reprocessing the first 60?
What is Provisioned Concurrency and when would you use it? What are the cost implications?
How does Lambda's scaling work? What are the concurrency limits and how do you handle throttling?
Frequently Asked Questions
Lambda charges on two axes: number of requests ($0.20 per 1 million requests) and duration rounded to the nearest 1ms ($0.0000166667 per GB-second). The free tier covers 1 million requests and 400,000 GB-seconds per month permanently — not just the first year. A function using 512 MB running for 200ms, invoked 5 million times a month, costs roughly $8. Compare that to a t3.small EC2 at ~$15/month that sits idle most of the time.
A cold start is the initialisation delay when Lambda has to provision a fresh execution environment because no warm container is available. It includes downloading your code, starting the runtime, and running module-level initialisation code. Provisioned Concurrency is the only way to fully eliminate cold starts — you pay to keep N containers permanently warm. Keeping package sizes small (under 5 MB) and using lighter runtimes (Python, Node.js) minimises cold start duration but doesn't eliminate the occurrence.
Lambda has a hard 15-minute maximum execution timeout. For jobs that run longer than that — nightly batch reports, large file processing, ML model training — you need a different tool. AWS Step Functions can chain multiple Lambda calls to work around the timeout for sequential tasks. For truly long-running jobs, AWS Fargate (containerised tasks) or AWS Batch are the right choices. Trying to hack around Lambda's timeout with recursive self-invocation is an anti-pattern and will create billing surprises.
Start by checking the CloudWatch Logs for the log stream of that invocation. Look for 'Task timed out after X seconds' line. Increase the function's timeout in the configuration (max 15 minutes) to see if it's just a wall clock issue. If it still times out, use AWS X-Ray to trace the request and identify which downstream call (database, external API, S3) is slow. Consider adding timeouts on individual client calls to avoid hanging forever. Also check if your function is waiting on a synchronous SDK call without timeout configured.
Use .zip deployment packages unless you specifically need a container image (e.g., large dependencies, custom runtime, need to use Docker tooling). Container images have slower cold starts because Lambda has to pull the entire image before invocation. .zip packages are smaller and faster to download. Use Lambda Layers to separate large dependencies from your code. If you must use containers, use multi-stage builds and a minimal base image (e.g., public.ecr.aws/lambda/python:latest).
20+ years shipping production infrastructure and CI/CD at scale. Everything here is grounded in real deployments.
That's AWS. Mark it forged?
15 min read · try the examples if you haven't