AWS Lambda Best Practices: Performance, Cost, Security
Master AWS Lambda best practices for optimizing performance, reducing cold starts, controlling costs, and securing your serverless functions in production.
What you'll learn
- ✓Optimize Lambda cold starts and execution performance
- ✓Control costs with memory tuning and provisioned concurrency
- ✓Secure functions with least-privilege IAM and VPC placement
- ✓Structure Lambda code for maintainability and testability
Prerequisites
- •Basic AWS Lambda knowledge
- •AWS CLI installed and configured
- •Familiarity with at least one Lambda runtime (Python, Node.js, etc.)
AWS Lambda has become the backbone of serverless architectures, but deploying a function is only the beginning. Production workloads demand careful attention to performance, cost, and security. This guide walks through battle-tested practices that separate proof-of-concept Lambdas from production-grade ones.
Understanding Cold Starts and How to Minimize Them
A cold start occurs when AWS must provision a new execution environment for your function. This involves downloading your deployment package, initializing the runtime, and running your initialization code. Cold starts can add anywhere from 100ms to several seconds of latency.
The biggest factors affecting cold start duration are package size, runtime choice, and VPC configuration. Here is how to tackle each one.
Keep Deployment Packages Small
Every megabyte in your deployment package adds to cold start time. Strip out unnecessary dependencies, use Lambda layers for shared libraries, and consider using tree-shaking or bundling tools.
# Check your current package size
aws lambda get-function --function-name my-function \
--query 'Configuration.CodeSize' --output text
# Use Lambda layers for shared dependencies
aws lambda publish-layer-version \
--layer-name shared-utils \
--zip-file fileb://layer.zip \
--compatible-runtimes python3.12
Use Provisioned Concurrency for Latency-Sensitive Workloads
Provisioned concurrency keeps a specified number of execution environments warm and ready to respond instantly.
# Set provisioned concurrency on a function alias
aws lambda put-provisioned-concurrency-config \
--function-name my-function \
--qualifier prod \
--provisioned-concurrent-executions 10
# Check provisioned concurrency status
aws lambda get-provisioned-concurrency-config \
--function-name my-function \
--qualifier prod
# CloudFormation for provisioned concurrency
Resources:
MyFunction:
Type: AWS::Lambda::Function
Properties:
FunctionName: my-function
Runtime: python3.12
Handler: index.handler
Code:
S3Bucket: my-deployment-bucket
S3Key: function.zip
MemorySize: 256
Timeout: 30
ProdAlias:
Type: AWS::Lambda::Alias
Properties:
FunctionName: !Ref MyFunction
FunctionVersion: !GetAtt MyFunction.Version
Name: prod
ProvisionedConcurrency:
Type: AWS::Lambda::ProvisionedConcurrencyConfig
Properties:
FunctionName: !Ref MyFunction
Qualifier: prod
ProvisionedConcurrentExecutions: 10
Initialize Connections Outside the Handler
Code outside your handler function runs only during cold starts. Use this to initialize database connections, SDK clients, and other reusable resources.
import boto3
import os
# These run once during cold start - reused across invocations
dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table(os.environ['TABLE_NAME'])
ssm = boto3.client('ssm')
# Cache configuration values
config_param = ssm.get_parameter(
Name='/my-app/config',
WithDecryption=True
)
CONFIG = config_param['Parameter']['Value']
def handler(event, context):
# This runs on every invocation - keep it lean
item = table.get_item(Key={'id': event['id']})
return {
'statusCode': 200,
'body': item['Item']
}
Memory and Timeout Tuning
Lambda allocates CPU proportionally to the memory you configure. A function with 1,769 MB gets one full vCPU. This means increasing memory does not just give you more RAM — it also gives you more CPU, which can make your function run faster and sometimes cost less overall.
Use AWS Lambda Power Tuning
The open-source AWS Lambda Power Tuning tool runs your function at different memory settings and shows you the cost-performance tradeoff.
# Deploy the power tuning state machine
aws serverlessrepo create-cloud-formation-change-set \
--application-id arn:aws:serverlessrepo:us-east-1:451282441545:applications/aws-lambda-power-tuning \
--stack-name lambda-power-tuning \
--capabilities CAPABILITY_IAM
# Run the tuning with a test payload
aws stepfunctions start-execution \
--state-machine-arn arn:aws:states:us-east-1:123456789:stateMachine:powerTuningStateMachine \
--input '{
"lambdaARN": "arn:aws:lambda:us-east-1:123456789:function:my-function",
"powerValues": [128, 256, 512, 1024, 1769, 3008],
"num": 50,
"payload": {"key": "value"}
}'
Set Appropriate Timeouts
Never use the maximum 15-minute timeout as a default. Set timeouts slightly above your expected execution time to fail fast and avoid runaway costs.
Resources:
MyFunction:
Type: AWS::Lambda::Function
Properties:
Timeout: 30 # Not 900
MemorySize: 512
ReservedConcurrentExecutions: 100 # Prevent runaway scaling
Cost Optimization Strategies
Lambda billing is based on the number of invocations and the duration in milliseconds multiplied by the memory allocated. Small optimizations compound at scale.
Use ARM64 (Graviton2) for 20% Cost Savings
Lambda functions running on ARM64 architecture are approximately 20% cheaper and often perform better for compute-bound workloads.
# Create or update a function to use ARM64
aws lambda create-function \
--function-name my-function \
--runtime python3.12 \
--architectures arm64 \
--handler index.handler \
--zip-file fileb://function.zip \
--role arn:aws:iam::123456789:role/lambda-role
Batch Processing to Reduce Invocations
When processing records from SQS or DynamoDB Streams, configure batch sizes to reduce the number of invocations.
Resources:
SQSEventMapping:
Type: AWS::Lambda::EventSourceMapping
Properties:
FunctionName: !Ref ProcessingFunction
EventSourceArn: !GetAtt MyQueue.Arn
BatchSize: 10
MaximumBatchingWindowInSeconds: 5
FunctionResponseTypes:
- ReportBatchItemFailures
def handler(event, context):
batch_item_failures = []
for record in event['Records']:
try:
process_message(record)
except Exception as e:
batch_item_failures.append({
'itemIdentifier': record['messageId']
})
return {'batchItemFailures': batch_item_failures}
Security Best Practices
Apply Least-Privilege IAM Policies
Each Lambda function should have its own IAM role with only the permissions it needs. Avoid wildcard actions and resources.
Resources:
FunctionRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal:
Service: lambda.amazonaws.com
Action: sts:AssumeRole
Policies:
- PolicyName: MinimalAccess
PolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Action:
- dynamodb:GetItem
- dynamodb:PutItem
- dynamodb:Query
Resource: !GetAtt MyTable.Arn
- Effect: Allow
Action:
- logs:CreateLogGroup
- logs:CreateLogStream
- logs:PutLogEvents
Resource: 'arn:aws:logs:*:*:*'
Use Environment Variables with Encryption
Never hardcode secrets. Use environment variables encrypted with AWS KMS, or better yet, reference AWS Secrets Manager or SSM Parameter Store.
# Create an encrypted environment variable
aws lambda update-function-configuration \
--function-name my-function \
--environment "Variables={DB_HOST=mydb.cluster.us-east-1.rds.amazonaws.com}" \
--kms-key-arn arn:aws:kms:us-east-1:123456789:key/my-key-id
import boto3
from functools import lru_cache
@lru_cache(maxsize=1)
def get_secret():
client = boto3.client('secretsmanager')
response = client.get_secret_value(SecretId='my-app/db-credentials')
return response['SecretString']
def handler(event, context):
credentials = get_secret() # Cached after first call
# Use credentials...
Enable VPC Access Only When Necessary
Placing Lambda in a VPC adds cold start latency and operational complexity. Only do it when your function needs to access VPC resources like RDS or ElastiCache.
Resources:
VPCFunction:
Type: AWS::Lambda::Function
Properties:
VpcConfig:
SecurityGroupIds:
- !Ref LambdaSecurityGroup
SubnetIds:
- !Ref PrivateSubnet1
- !Ref PrivateSubnet2
# Function needs NAT Gateway for internet access when in VPC
Observability and Error Handling
Structured Logging
Use structured JSON logging to make your logs searchable in CloudWatch Logs Insights.
import json
import logging
logger = logging.getLogger()
logger.setLevel(logging.INFO)
def handler(event, context):
logger.info(json.dumps({
'event': 'processing_started',
'request_id': context.aws_request_id,
'remaining_time_ms': context.get_remaining_time_in_millis(),
'input_count': len(event.get('records', []))
}))
try:
result = process(event)
logger.info(json.dumps({
'event': 'processing_complete',
'request_id': context.aws_request_id,
'items_processed': result['count']
}))
return result
except Exception as e:
logger.error(json.dumps({
'event': 'processing_failed',
'request_id': context.aws_request_id,
'error': str(e),
'error_type': type(e).__name__
}))
raise
Dead Letter Queues for Async Invocations
Configure a dead letter queue to capture failed asynchronous invocations for later analysis and reprocessing.
Resources:
DeadLetterQueue:
Type: AWS::SQS::Queue
Properties:
QueueName: my-function-dlq
MessageRetentionPeriod: 1209600 # 14 days
MyFunction:
Type: AWS::Lambda::Function
Properties:
DeadLetterConfig:
TargetArn: !GetAtt DeadLetterQueue.Arn
Code Organization and Testing
Structure your Lambda code to separate the handler from business logic. This makes unit testing straightforward without needing to mock the Lambda runtime.
# business_logic.py - testable without Lambda
def calculate_order_total(items, tax_rate):
subtotal = sum(item['price'] * item['quantity'] for item in items)
tax = subtotal * tax_rate
return {'subtotal': subtotal, 'tax': tax, 'total': subtotal + tax}
# handler.py - thin wrapper
from business_logic import calculate_order_total
def handler(event, context):
items = event['items']
tax_rate = float(os.environ.get('TAX_RATE', '0.08'))
result = calculate_order_total(items, tax_rate)
return {'statusCode': 200, 'body': json.dumps(result)}
# test_business_logic.py - no Lambda mocking needed
def test_calculate_order_total():
items = [{'price': 10.0, 'quantity': 2}, {'price': 5.0, 'quantity': 1}]
result = calculate_order_total(items, 0.1)
assert result['subtotal'] == 25.0
assert result['tax'] == 2.5
assert result['total'] == 27.5
Wrapping Up
AWS Lambda best practices boil down to a few core principles: keep packages small and initialization efficient to minimize cold starts, tune memory and use ARM64 to optimize costs, apply least-privilege IAM and encrypt secrets for security, and use structured logging with dead letter queues for observability. Start by applying the practices that address your biggest pain points — whether that is latency, cost, or operational visibility — and iterate from there. Every function does not need every optimization, but every production function should have proper IAM scoping, structured logging, and appropriate timeout configuration as a baseline.
Related articles
- AWS AWS Lambda Cold Starts: A Deep Dive
What actually happens during a Lambda cold start, why some functions are worse than others, and the techniques that meaningfully reduce p99 latency in production.
- AWS AWS Step Functions Tutorial: Orchestrating Serverless Workflows
Learn how AWS Step Functions coordinate Lambda, ECS, and SDK calls into reliable state machines, with patterns for retries, parallelism, and error handling.
- AWS AWS Lambda Basics: Serverless Functions
A beginner-friendly tour of AWS Lambda — the handler signature, runtime choices, triggers from API Gateway and S3 and EventBridge, cold starts, packaging, and the IAM execution role every function needs.
- AWS AWS Cost Optimization: 10 Ways to Reduce Your Bill
Cut your AWS bill with 10 proven cost optimization strategies covering Reserved Instances, right-sizing, S3 tiers, spot instances, and more.