AWS Lambda: Building Serverless Applications
A comprehensive guide to AWS Lambda for serverless computing. Function design patterns, cold starts, performance optimization, and production best practices.
AWS Lambda: Building Serverless Applications
AWS Lambda lets you run code without provisioning or managing servers. You pay only for compute time consumed—no charge when your code isn't running. Understanding Lambda's execution model and best practices is essential for building reliable serverless applications.
Lambda Fundamentals
Execution Model
Lambda Execution Lifecycle:
├── Cold Start
│ ├── Download code from S3/Container registry
│ ├── Create execution environment
│ ├── Initialize runtime
│ ├── Run initialization code (outside handler)
│ └── Execute handler
│
└── Warm Start (reused environment)
└── Execute handler directly
Environment Reuse:
┌─────────────────────────────────────────────────────┐
│ Execution Environment │
│ ┌──────────────────────────────────────────────┐ │
│ │ /tmp (512MB-10GB, persists) │ │
│ └──────────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────────┐ │
│ │ Initialization Code (runs once) │ │
│ │ - SDK clients │ │
│ │ - Database connections │ │
│ │ - Configuration loading │ │
│ └──────────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────────┐ │
│ │ Handler (runs per invocation) │ │
│ └──────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘Configuration Options
| Setting | Range | Default | Consideration |
|---|---|---|---|
| Memory | 128MB - 10,240MB | 128MB | CPU scales proportionally |
| Timeout | 1s - 900s | 3s | Max 15 minutes |
| Ephemeral Storage | 512MB - 10,240MB | 512MB | /tmp directory |
| Concurrent Executions | Account limit | 1,000 | Can be reserved |
Function Design Patterns
Basic Handler Structure
// handler.ts
import { APIGatewayProxyEvent, APIGatewayProxyResult, Context } from 'aws-lambda';
// Initialization code - runs once per cold start
const dynamoClient = new DynamoDBClient({});
const docClient = DynamoDBDocumentClient.from(dynamoClient);
// Handler - runs per invocation
export const handler = async (
event: APIGatewayProxyEvent,
context: Context
): Promise<APIGatewayProxyResult> => {
// Log request for debugging
console.log('Request ID:', context.awsRequestId);
console.log('Remaining time:', context.getRemainingTimeInMillis());
try {
const body = JSON.parse(event.body || '{}');
// Business logic
const result = await processRequest(body);
return {
statusCode: 200,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
},
body: JSON.stringify(result)
};
} catch (error) {
console.error('Error processing request:', error);
return {
statusCode: error instanceof ValidationError ? 400 : 500,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
error: error instanceof ValidationError
? error.message
: 'Internal server error'
})
};
}
};Event-Driven Patterns
// event-patterns.ts
// Pattern 1: SQS Message Processing
import { SQSEvent, SQSRecord } from 'aws-lambda';
export const sqsHandler = async (event: SQSEvent): Promise<void> => {
const failedMessageIds: string[] = [];
for (const record of event.Records) {
try {
await processMessage(record);
} catch (error) {
console.error(`Failed to process message ${record.messageId}:`, error);
failedMessageIds.push(record.messageId);
}
}
// Partial batch failure reporting
if (failedMessageIds.length > 0) {
throw new Error(`Failed messages: ${failedMessageIds.join(', ')}`);
}
};
// Pattern 2: S3 Event Processing
import { S3Event } from 'aws-lambda';
export const s3Handler = async (event: S3Event): Promise<void> => {
for (const record of event.Records) {
const bucket = record.s3.bucket.name;
const key = decodeURIComponent(record.s3.object.key.replace(/\+/g, ' '));
console.log(`Processing ${bucket}/${key}`);
if (record.eventName.startsWith('ObjectCreated')) {
await processNewObject(bucket, key);
} else if (record.eventName.startsWith('ObjectRemoved')) {
await handleDeletedObject(bucket, key);
}
}
};
// Pattern 3: DynamoDB Stream Processing
import { DynamoDBStreamEvent, StreamRecord } from 'aws-lambda';
export const dynamoHandler = async (event: DynamoDBStreamEvent): Promise<void> => {
for (const record of event.Records) {
const eventName = record.eventName;
const keys = record.dynamodb?.Keys;
const newImage = record.dynamodb?.NewImage;
const oldImage = record.dynamodb?.OldImage;
switch (eventName) {
case 'INSERT':
await handleInsert(newImage);
break;
case 'MODIFY':
await handleModify(oldImage, newImage);
break;
case 'REMOVE':
await handleRemove(oldImage);
break;
}
}
};
// Pattern 4: Scheduled Events (CloudWatch Events/EventBridge)
import { ScheduledEvent } from 'aws-lambda';
export const scheduledHandler = async (event: ScheduledEvent): Promise<void> => {
console.log(`Running scheduled task at ${event.time}`);
// Run cleanup, aggregation, or batch jobs
await runScheduledTask();
};Cold Start Optimisation
Reducing Cold Start Time
// cold-start-optimization.ts
// 1. Keep dependencies minimal
// Bad: import entire AWS SDK
import AWS from 'aws-sdk'; // Imports everything
// Good: import only what you need
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
// 2. Initialize outside handler
const client = new DynamoDBClient({}); // Runs once on cold start
export const handler = async (event: any) => {
// Handler code uses pre-initialized client
return await client.send(command);
};
// 3. Use lazy initialization for rarely-used dependencies
let s3Client: S3Client | null = null;
const getS3Client = (): S3Client => {
if (!s3Client) {
s3Client = new S3Client({});
}
return s3Client;
};
// 4. Connection pooling for databases
import { Pool } from 'pg';
const pool = new Pool({
host: process.env.DB_HOST,
max: 1, // Lambda best practice: minimal connections
idleTimeoutMillis: 120000,
connectionTimeoutMillis: 5000
});Provisioned Concurrency
# serverless.yml
functions:
api:
handler: src/handler.main
provisionedConcurrency: 5 # Keep 5 instances warm
# terraform
resource "aws_lambda_provisioned_concurrency_config" "api" {
function_name = aws_lambda_function.api.function_name
provisioned_concurrent_executions = 5
qualifier = aws_lambda_function.api.version
}Cold Start Comparison by Runtime
| Runtime | Cold Start (p50) | Cold Start (p99) | Notes |
|---|---|---|---|
| Node.js 20.x | 150-300ms | 500-800ms | Fast startup |
| Python 3.12 | 150-300ms | 500-800ms | Fast startup |
| Go 1.x | 100-200ms | 300-500ms | Fastest |
| Java 21 | 1-3s | 3-8s | Use SnapStart |
| .NET 8 | 400-800ms | 1-2s | AOT helps |
Error Handling and Retry Logic
Implementing Idempotency
// idempotency.ts
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import {
DynamoDBDocumentClient,
PutCommand,
GetCommand
} from '@aws-sdk/lib-dynamodb';
const docClient = DynamoDBDocumentClient.from(new DynamoDBClient({}));
interface IdempotencyRecord {
idempotencyKey: string;
status: 'IN_PROGRESS' | 'COMPLETED' | 'FAILED';
result?: any;
expiresAt: number;
}
const IDEMPOTENCY_TABLE = process.env.IDEMPOTENCY_TABLE!;
const TTL_SECONDS = 3600; // 1 hour
export const withIdempotency = async <T>(
idempotencyKey: string,
operation: () => Promise<T>
): Promise<T> => {
// Check for existing execution
const existing = await docClient.send(new GetCommand({
TableName: IDEMPOTENCY_TABLE,
Key: { idempotencyKey }
}));
if (existing.Item) {
const record = existing.Item as IdempotencyRecord;
if (record.status === 'COMPLETED') {
console.log(`Returning cached result for ${idempotencyKey}`);
return record.result as T;
}
if (record.status === 'IN_PROGRESS') {
throw new Error('Operation already in progress');
}
}
// Mark as in progress
const expiresAt = Math.floor(Date.now() / 1000) + TTL_SECONDS;
await docClient.send(new PutCommand({
TableName: IDEMPOTENCY_TABLE,
Item: {
idempotencyKey,
status: 'IN_PROGRESS',
expiresAt
},
ConditionExpression: 'attribute_not_exists(idempotencyKey) OR #status = :failed',
ExpressionAttributeNames: { '#status': 'status' },
ExpressionAttributeValues: { ':failed': 'FAILED' }
}));
try {
const result = await operation();
// Mark as completed
await docClient.send(new PutCommand({
TableName: IDEMPOTENCY_TABLE,
Item: {
idempotencyKey,
status: 'COMPLETED',
result,
expiresAt
}
}));
return result;
} catch (error) {
// Mark as failed
await docClient.send(new PutCommand({
TableName: IDEMPOTENCY_TABLE,
Item: {
idempotencyKey,
status: 'FAILED',
error: (error as Error).message,
expiresAt
}
}));
throw error;
}
};
// Usage
export const handler = async (event: APIGatewayProxyEvent) => {
const requestId = event.headers['x-request-id'] || event.requestContext.requestId;
const result = await withIdempotency(requestId, async () => {
return await processPayment(event);
});
return { statusCode: 200, body: JSON.stringify(result) };
};Dead Letter Queues
// dlq-handler.ts
import { SQSEvent, SQSRecord } from 'aws-lambda';
interface DLQMessage {
originalMessage: any;
errorMessage: string;
failureCount: number;
originalQueue: string;
timestamp: string;
}
export const dlqHandler = async (event: SQSEvent): Promise<void> => {
for (const record of event.Records) {
const dlqMessage = parseDLQMessage(record);
// Log for analysis
console.error('DLQ Message:', JSON.stringify({
messageId: record.messageId,
errorMessage: dlqMessage.errorMessage,
failureCount: dlqMessage.failureCount,
originalQueue: dlqMessage.originalQueue
}));
// Alert if threshold exceeded
if (dlqMessage.failureCount >= 3) {
await sendAlert({
type: 'DLQ_THRESHOLD',
message: `Message failed ${dlqMessage.failureCount} times`,
messageId: record.messageId
});
}
// Store for later analysis/retry
await storeDLQMessage(dlqMessage);
}
};Observability
Structured Logging
// logger.ts
interface LogContext {
requestId: string;
functionName: string;
functionVersion: string;
traceId?: string;
}
class StructuredLogger {
private context: LogContext;
constructor(context: LogContext) {
this.context = context;
}
info(message: string, data?: Record<string, any>): void {
this.log('INFO', message, data);
}
error(message: string, error?: Error, data?: Record<string, any>): void {
this.log('ERROR', message, {
...data,
error: error ? {
name: error.name,
message: error.message,
stack: error.stack
} : undefined
});
}
private log(level: string, message: string, data?: Record<string, any>): void {
console.log(JSON.stringify({
timestamp: new Date().toISOString(),
level,
message,
...this.context,
...data
}));
}
}
// Usage in handler
export const handler = async (event: any, context: Context) => {
const logger = new StructuredLogger({
requestId: context.awsRequestId,
functionName: context.functionName,
functionVersion: context.functionVersion,
traceId: process.env._X_AMZN_TRACE_ID
});
logger.info('Processing request', { eventType: event.type });
try {
const result = await processEvent(event);
logger.info('Request completed', { resultCount: result.length });
return result;
} catch (error) {
logger.error('Request failed', error as Error);
throw error;
}
};Custom Metrics
// metrics.ts
import {
CloudWatchClient,
PutMetricDataCommand
} from '@aws-sdk/client-cloudwatch';
const cloudwatch = new CloudWatchClient({});
interface Metric {
name: string;
value: number;
unit: 'Count' | 'Milliseconds' | 'Bytes';
dimensions?: Record<string, string>;
}
const publishMetrics = async (metrics: Metric[]): Promise<void> => {
const namespace = `${process.env.SERVICE_NAME}/Lambda`;
await cloudwatch.send(new PutMetricDataCommand({
Namespace: namespace,
MetricData: metrics.map(m => ({
MetricName: m.name,
Value: m.value,
Unit: m.unit,
Timestamp: new Date(),
Dimensions: m.dimensions
? Object.entries(m.dimensions).map(([Name, Value]) => ({ Name, Value }))
: undefined
}))
}));
};
// Timing wrapper
const withTiming = async <T>(
name: string,
operation: () => Promise<T>
): Promise<T> => {
const start = Date.now();
try {
const result = await operation();
await publishMetrics([{
name: `${name}Duration`,
value: Date.now() - start,
unit: 'Milliseconds'
}, {
name: `${name}Success`,
value: 1,
unit: 'Count'
}]);
return result;
} catch (error) {
await publishMetrics([{
name: `${name}Error`,
value: 1,
unit: 'Count'
}]);
throw error;
}
};Infrastructure as Code
Terraform Configuration
# lambda.tf
resource "aws_lambda_function" "api" {
function_name = "${var.service_name}-api"
role = aws_iam_role.lambda_role.arn
handler = "dist/handler.main"
runtime = "nodejs20.x"
filename = data.archive_file.lambda_zip.output_path
source_code_hash = data.archive_file.lambda_zip.output_base64sha256
memory_size = 1024
timeout = 30
environment {
variables = {
NODE_ENV = var.environment
TABLE_NAME = aws_dynamodb_table.main.name
LOG_LEVEL = "INFO"
}
}
vpc_config {
subnet_ids = var.private_subnet_ids
security_group_ids = [aws_security_group.lambda.id]
}
tracing_config {
mode = "Active"
}
reserved_concurrent_executions = 100
dead_letter_config {
target_arn = aws_sqs_queue.dlq.arn
}
tags = var.tags
}
# IAM Role
resource "aws_iam_role" "lambda_role" {
name = "${var.service_name}-lambda-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "lambda.amazonaws.com"
}
}]
})
}
resource "aws_iam_role_policy_attachment" "lambda_basic" {
role = aws_iam_role.lambda_role.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole"
}
resource "aws_iam_role_policy" "lambda_permissions" {
name = "${var.service_name}-lambda-permissions"
role = aws_iam_role.lambda_role.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"dynamodb:GetItem",
"dynamodb:PutItem",
"dynamodb:Query",
"dynamodb:UpdateItem"
]
Resource = aws_dynamodb_table.main.arn
},
{
Effect = "Allow"
Action = [
"sqs:SendMessage"
]
Resource = aws_sqs_queue.dlq.arn
}
]
})
}
# CloudWatch Log Group
resource "aws_cloudwatch_log_group" "lambda" {
name = "/aws/lambda/${aws_lambda_function.api.function_name}"
retention_in_days = 14
}Key Takeaways
-
Initialise outside handler: Move SDK clients and connections outside the handler function
-
Minimise dependencies: Smaller deployment packages mean faster cold starts
-
Handle errors gracefully: Implement idempotency and use DLQs for failed messages
-
Monitor everything: Structured logging and custom metrics are essential
-
Right-size memory: More memory = more CPU = potentially lower duration costs
-
Consider provisioned concurrency: For latency-sensitive workloads
-
Use layers: Share common code and reduce deployment size
-
Design for failure: Assume functions can fail and build in retry logic
Lambda enables building scalable, cost-effective applications. Focus on function design, proper error handling, and observability to build production-ready serverless systems.