Serverless Architecture Patterns
When to Use Serverless
Good fit:
- Event-driven workloads (image processing, notifications)
- Unpredictable traffic (variable load)
- Batch processing jobs
- Webhooks and integrations
- Low-traffic APIs
Poor fit:
- Long-running processes (>15 min)
- High-traffic APIs (cost at scale)
- Stateful applications
- Latency-sensitive (cold starts)
- GPU workloads
Function Composition Patterns
Event-Driven Pipeline
// S3 -> Lambda -> SQS -> Lambda -> DynamoDB
// Each function does one thing
// Image uploaded to S3
export const onImageUpload: S3Handler = async (event) => {
for (const record of event.Records) {
const bucket = record.s3.bucket.name;
const key = record.s3.object.key;
// Send to processing queue (decouple)
await sqs.sendMessage({
QueueUrl: process.env.PROCESSING_QUEUE_URL!,
MessageBody: JSON.stringify({ bucket, key }),
}).promise();
}
};
// Process from SQS
export const processImage: SQSHandler = async (event) => {
for (const record of event.Records) {
const { bucket, key } = JSON.parse(record.body);
await resizeAndOptimize(bucket, key);
await storeMetadata(bucket, key);
}
};
Saga with Step Functions
{
"Comment": "Order processing saga",
"StartAt": "ValidateOrder",
"States": {
"ValidateOrder": {
"Type": "Task",
"Resource": "arn:aws:lambda:us-east-1:123:function:validateOrder",
"Next": "ChargePayment",
"Catch": [{ "ErrorEquals": ["ValidationError"], "Next": "OrderFailed" }]
},
"ChargePayment": {
"Type": "Task",
"Resource": "arn:aws:lambda:us-east-1:123:function:chargePayment",
"Next": "ReserveInventory",
"Catch": [{ "ErrorEquals": ["PaymentFailed"], "Next": "OrderFailed" }]
},
"ReserveInventory": {
"Type": "Task",
"Resource": "arn:aws:lambda:us-east-1:123:function:reserveInventory",
"Next": "OrderComplete"
},
"OrderComplete": { "Type": "Succeed" },
"OrderFailed": { "Type": "Fail" }
}
}
Cold Start Mitigation
// 1. Provisioned Concurrency (AWS)
// In serverless.yml:
// provisionedConcurrency: 5 # Always warm instances
// 2. Scheduled warmup
export const warmup: ScheduledHandler = async () => {
// This function runs every 5 minutes to keep Lambda warm
return { statusCode: 200, body: 'warmed' };
};
// 3. Minimize package size
// Use esbuild bundling, tree-shake unused modules
// Avoid importing entire AWS SDK - use v3 modular clients:
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
// Not: import AWS from 'aws-sdk';
// 4. Keep initialization outside handler
const client = new DynamoDBClient({ region: 'us-east-1' }); // Reused across invocations
export const handler = async (event: APIGatewayEvent) => { /* ... */ };
Stateless Design Patterns
// Problem: Storing state in-memory (lost on cold start)
const cache = new Map<string, User>(); // Bad! Lost between invocations
// Solution 1: External cache
async function getUser(id: string): Promise<User> {
return redis.getOrSet(`user:${id}`, () => db.findUser(id), 300);
}
// Solution 2: Pass state through events
export const handler = async (event: { userId: string; sessionToken: string }) => {
// All needed state comes from the event
const user = await validateToken(event.sessionToken);
return processRequest(user, event);
};
// Solution 3: DynamoDB for distributed state
const session = await dynamodb.getItem({ TableName: 'sessions', Key: { id: { S: sessionId } } });
Cost Optimization
// Use ARM64 (Graviton) for ~20% cost savings
// serverless.yml: architecture: arm64
// Right-size memory (more memory = more CPU + cost)
// Profile actual memory usage:
export const handler = async () => {
const used = process.memoryUsage().heapUsed / 1024 / 1024;
console.log(`Memory used: ${Math.round(used)}MB`);
// Set Lambda memory to ~1.5x actual usage
};
// Batch SQS messages to reduce invocations
// SQS trigger with batch size 10:
export const batchHandler: SQSHandler = async (event) => {
await Promise.all(event.Records.map(r => processRecord(JSON.parse(r.body))));
// 1 invocation processes 10 messages instead of 10 invocations
};
Serverless shines for event-driven, variable workloads but requires rethinking traditional architectures.