Skip to content
Codeloom
AWS

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.

·7 min read · By Codeloom
Intermediate 18 min read

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.