Why Serverless?
After building multiple production applications on AWS Lambda + DynamoDB, the value proposition is clear:
Pros:
- Zero server management
- Automatic scaling (0 to thousands of requests)
- Pay-per-use pricing (idle = $0)
- Built-in fault tolerance
- Global distribution (if needed)
Cons:
- Cold starts (mitigatable)
- Stateless execution model
- Vendor lock-in
- Learning curve for NoSQL
When to Use:
- Variable/unpredictable traffic
- Event-driven architecture
- API backends
- Data processing pipelines
- Cost-sensitive applications
DynamoDB Schema Design Patterns
DynamoDB schema design is fundamentally different from SQL databases. Plan your access patterns first, then design your schema around them. Changing schemas later is difficult and expensive.
Pattern 1: Single Table Design
Concept: Store multiple entity types in one table using composite keys.
Separate tables for each entity:
- Users table: userId, name, email
- Posts table: postId, userId, title
- Comments table: commentId, postId, text
Issues:
- Multiple queries needed
- Higher costs (per table)
- No cross-entity transactions
Single AppTable with composite keys:
- User: PK=USER#123, SK=PROFILE
- Post: PK=USER#123, SK=POST#456
- Comment: PK=POST#456, SK=COMMENT#789
Benefits:
- Single query for related data
- Lower costs
- Atomic transactions
// Single table design
AppTable: { PK, SK, ...attributes }
// User entity
PK: USER#123
SK: PROFILE
attributes: { name, email, createdAt }
// Post entity
PK: USER#123
SK: POST#456
attributes: { title, content, publishedAt }
// Comment entity
PK: POST#456
SK: COMMENT#789
attributes: { text, authorId, createdAt }
Benefits:
- Single query fetches related data
- Reduced costs (fewer tables)
- Atomic transactions across entities
- Easier to reason about access patterns
Schema Implementation:
interface DynamoDBRecord {
PK: string; // Partition Key
SK: string; // Sort Key
GSI1PK?: string; // Global Secondary Index 1 PK
GSI1SK?: string; // Global Secondary Index 1 SK
EntityType: string;
[key: string]: any;
}
// User record
const userRecord: DynamoDBRecord = {
PK: 'USER#abc123',
SK: 'PROFILE',
GSI1PK: 'EMAIL#user@example.com',
GSI1SK: 'USER#abc123',
EntityType: 'User',
name: 'John Doe',
email: 'user@example.com',
createdAt: '2024-01-01T00:00:00Z',
};
// Post record
const postRecord: DynamoDBRecord = {
PK: 'USER#abc123',
SK: 'POST#2024-01-15#xyz789',
GSI1PK: 'POST#xyz789',
GSI1SK: '2024-01-15T12:00:00Z',
EntityType: 'Post',
title: 'My First Post',
content: 'Hello world',
status: 'published',
};
Pattern 2: Inverted Indexes with GSI
Problem: Query data in multiple ways without scanning.
Solution: Use Global Secondary Indexes (GSI) to invert query patterns.
// Base table: Query user's posts
PK: USER#123
SK: POST#456
// GSI: Query post's details
GSI1PK: POST#456
GSI1SK: USER#123
// GSI2: Query posts by status
GSI2PK: STATUS#published
GSI2SK: 2024-01-15#POST#456
Implementation:
// Query user's posts
const getUserPosts = async (userId: string) => {
const result = await docClient.query({
TableName: TABLE_NAME,
KeyConditionExpression: 'PK = :pk AND begins_with(SK, :sk)',
ExpressionAttributeValues: {
':pk': `USER#${userId}`,
':sk': 'POST#',
},
});
return result.Items;
};
// Query post details via GSI
const getPostById = async (postId: string) => {
const result = await docClient.query({
TableName: TABLE_NAME,
IndexName: 'GSI1',
KeyConditionExpression: 'GSI1PK = :pk',
ExpressionAttributeValues: {
':pk': `POST#${postId}`,
},
});
return result.Items[0];
};
// Query published posts via GSI2
const getPublishedPosts = async (limit: number = 20) => {
const result = await docClient.query({
TableName: TABLE_NAME,
IndexName: 'GSI2',
KeyConditionExpression: 'GSI2PK = :status',
ExpressionAttributeValues: {
':status': 'STATUS#published',
},
ScanIndexForward: false, // Descending order
Limit: limit,
});
return result.Items;
};
Pattern 3: Time Series Data with TTL
Use Case: Store events with automatic expiration.
interface EventRecord {
PK: string; // USER#123
SK: string; // EVENT#2024-01-15T12:00:00Z#abc
ttl: number; // Unix timestamp for auto-deletion
EventType: string;
data: any;
}
const createEvent = async (userId: string, eventType: string, data: any) => {
const timestamp = new Date().toISOString();
const ttl = Math.floor(Date.now() / 1000) + (30 * 24 * 60 * 60); // 30 days
await docClient.put({
TableName: TABLE_NAME,
Item: {
PK: `USER#${userId}`,
SK: `EVENT#${timestamp}#${uuid()}`,
ttl,
EventType: eventType,
data,
createdAt: timestamp,
},
});
};
TTL Configuration:
# Enable TTL on the 'ttl' attribute
aws dynamodb update-time-to-live \
--table-name YourTable \
--time-to-live-specification "Enabled=true, AttributeName=ttl"
Lambda Function Patterns
Pattern 1: API Gateway + Lambda
Architecture:
API Gateway → Lambda → DynamoDB
Implementation:
// handler.ts
import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, PutCommand } from '@aws-sdk/lib-dynamodb';
// Initialize outside handler for connection reuse
const client = new DynamoDBClient({});
const docClient = DynamoDBDocumentClient.from(client);
export const handler = async (
event: APIGatewayProxyEvent
): Promise<APIGatewayProxyResult> => {
// CORS headers
const headers = {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'Content-Type,Authorization',
};
try {
// Parse request
const body = JSON.parse(event.body || '{}');
const userId = event.requestContext.authorizer?.claims?.sub;
// Validate
if (!userId) {
return {
statusCode: 401,
headers,
body: JSON.stringify({ error: 'Unauthorized' }),
};
}
// Business logic
const result = await docClient.send(
new PutCommand({
TableName: process.env.TABLE_NAME,
Item: {
PK: `USER#${userId}`,
SK: `POST#${Date.now()}`,
...body,
createdAt: new Date().toISOString(),
},
})
);
return {
statusCode: 200,
headers,
body: JSON.stringify({ success: true }),
};
} catch (error) {
console.error('Error:', error);
return {
statusCode: 500,
headers,
body: JSON.stringify({
error: 'Internal server error',
message: error.message,
}),
};
}
};
Pattern 2: DynamoDB Streams + Lambda
Use Case: React to database changes (audit logs, cache invalidation, etc.)
Architecture:
DynamoDB → DynamoDB Streams → Lambda → S3/SNS/etc
Implementation:
// streamHandler.ts
import { DynamoDBStreamEvent } from 'aws-lambda';
import { unmarshall } from '@aws-sdk/util-dynamodb';
export const handler = async (event: DynamoDBStreamEvent) => {
for (const record of event.Records) {
console.log('Stream record:', record);
if (record.eventName === 'INSERT') {
const newItem = unmarshall(record.dynamodb!.NewImage!);
await handleNewItem(newItem);
}
if (record.eventName === 'MODIFY') {
const oldItem = unmarshall(record.dynamodb!.OldImage!);
const newItem = unmarshall(record.dynamodb!.NewImage!);
await handleUpdate(oldItem, newItem);
}
if (record.eventName === 'REMOVE') {
const oldItem = unmarshall(record.dynamodb!.OldImage!);
await handleDelete(oldItem);
}
}
};
async function handleNewItem(item: any) {
// Send notification, update cache, etc.
console.log('New item created:', item);
}
Pattern 3: Event-Driven with EventBridge
Architecture:
Lambda → EventBridge → Multiple Lambdas
Implementation:
// publisher.ts
import { EventBridgeClient, PutEventsCommand } from '@aws-sdk/client-eventbridge';
const eventBridge = new EventBridgeClient({});
export async function publishEvent(eventType: string, data: any) {
await eventBridge.send(
new PutEventsCommand({
Entries: [
{
Source: 'app.orders',
DetailType: eventType,
Detail: JSON.stringify(data),
EventBusName: process.env.EVENT_BUS_NAME,
},
],
})
);
}
// Usage
await publishEvent('OrderPlaced', {
orderId: '123',
userId: 'abc',
amount: 99.99,
});
// subscriber.ts
import { EventBridgeEvent } from 'aws-lambda';
export const handler = async (event: EventBridgeEvent<string, any>) => {
const { detail, 'detail-type': detailType } = event;
if (detailType === 'OrderPlaced') {
await sendConfirmationEmail(detail.userId, detail.orderId);
await updateInventory(detail.orderId);
await notifyWarehouse(detail.orderId);
}
};
Performance Optimization
1. Minimize Cold Starts
Problem: First request takes 1-3 seconds.
Solutions:
// A. Keep bundle size small
// Use esbuild or webpack to bundle only what's needed
// B. Use Provisioned Concurrency (costs money)
// terraform/lambda.tf
resource "aws_lambda_provisioned_concurrency_config" "api" {
function_name = aws_lambda_function.api.function_name
provisioned_concurrent_executions = 5
}
// C. Keep functions warm (free tier friendly)
// CloudWatch Event Rule to ping every 5 minutes
const keepWarm = (event: any) => {
if (event.source === 'aws.events') {
return { statusCode: 200, body: 'Warm' };
}
// Normal handler logic
};
2. Connection Reuse
// ❌ Bad: Create client inside handler
export const handler = async () => {
const client = new DynamoDBClient({}); // New connection every time!
// ...
};
// ✅ Good: Reuse connection across invocations
const client = new DynamoDBClient({});
const docClient = DynamoDBDocumentClient.from(client);
export const handler = async () => {
// Reuses existing connection
await docClient.send(new GetCommand({...}));
};
3. Batch Operations
// ❌ Bad: Individual writes
for (const item of items) {
await docClient.send(new PutCommand({ TableName, Item: item }));
}
// ✅ Good: Batch write (25 items at a time)
import { BatchWriteCommand } from '@aws-sdk/lib-dynamodb';
const chunks = chunkArray(items, 25);
for (const chunk of chunks) {
await docClient.send(
new BatchWriteCommand({
RequestItems: {
[TableName]: chunk.map(item => ({
PutRequest: { Item: item },
})),
},
})
);
}
4. Parallel Queries
// ❌ Bad: Sequential queries
const user = await getUser(userId);
const posts = await getUserPosts(userId);
const comments = await getUserComments(userId);
// ✅ Good: Parallel queries
const [user, posts, comments] = await Promise.all([
getUser(userId),
getUserPosts(userId),
getUserComments(userId),
]);
Cost Optimization
DynamoDB Pricing Modes
On-Demand (default):
- Pay per request
- Good for: Unpredictable traffic
- Cost: $1.25 per million reads, $6.25 per million writes
Provisioned:
- Pay for capacity (read/write units)
- Good for: Predictable, consistent traffic
- Cost: $0.00065 per RCU/hour, $0.00325 per WCU/hour
Auto-scaling provisioned (best of both):
// terraform/dynamodb.tf
resource "aws_appautoscaling_target" "dynamodb_table_read_target" {
max_capacity = 100
min_capacity = 5
resource_id = "table/${aws_dynamodb_table.main.name}"
scalable_dimension = "dynamodb:table:ReadCapacityUnits"
service_namespace = "dynamodb"
}
resource "aws_appautoscaling_policy" "dynamodb_table_read_policy" {
name = "DynamoDBReadCapacityUtilization:${aws_appautoscaling_target.dynamodb_table_read_target.resource_id}"
policy_type = "TargetTrackingScaling"
resource_id = aws_appautoscaling_target.dynamodb_table_read_target.resource_id
scalable_dimension = aws_appautoscaling_target.dynamodb_table_read_target.scalable_dimension
service_namespace = aws_appautoscaling_target.dynamodb_table_read_target.service_namespace
target_tracking_scaling_policy_configuration {
predefined_metric_specification {
predefined_metric_type = "DynamoDBReadCapacityUtilization"
}
target_value = 70.0
}
}
Lambda Cost Optimization
Memory = Speed = Cost:
- More memory = faster CPU = faster execution
- Sometimes 1024MB @ 100ms cheaper than 512MB @ 200ms
Find optimal memory:
# Use AWS Lambda Power Tuning
# https://github.com/alexcasalboni/aws-lambda-power-tuning
npm install -g lambda-power-tuning
lambda-power-tuning --function-name my-function
Example Results:
- 512MB: $0.0000083 per invocation (200ms avg)
- 1024MB: $0.0000079 per invocation (100ms avg) ← Winner
- 2048MB: $0.0000112 per invocation (60ms avg)
Monitoring & Debugging
CloudWatch Insights Queries
-- Find slowest invocations
fields @timestamp, @duration
| filter @type = "REPORT"
| sort @duration desc
| limit 20
-- Find errors
fields @timestamp, @message
| filter @type = "ERROR"
| stats count() by bin(5m)
-- Cold starts
fields @timestamp, @duration, @initDuration
| filter @type = "REPORT" and @initDuration > 0
| stats count() as coldStarts by bin(1h)
X-Ray Tracing
import AWSXRay from 'aws-xray-sdk-core';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
// Wrap AWS SDK calls
const client = AWSXRay.captureAWSv3Client(new DynamoDBClient({}));
// Custom subsegments
export const handler = async (event) => {
const segment = AWSXRay.getSegment();
const subsegment = segment.addNewSubsegment('business-logic');
try {
// Your logic here
subsegment.close();
} catch (error) {
subsegment.addError(error);
subsegment.close();
throw error;
}
};
Infrastructure as Code
Serverless Framework Example
# serverless.yml
service: my-api
provider:
name: aws
runtime: nodejs20.x
region: us-east-1
environment:
TABLE_NAME: ${self:service}-${self:provider.stage}
functions:
api:
handler: handler.handler
events:
- httpApi:
path: /items
method: post
- httpApi:
path: /items/{id}
method: get
resources:
Resources:
ItemsTable:
Type: AWS::DynamoDB::Table
Properties:
TableName: ${self:provider.environment.TABLE_NAME}
BillingMode: PAY_PER_REQUEST
AttributeDefinitions:
- AttributeName: PK
AttributeType: S
- AttributeName: SK
AttributeType: S
KeySchema:
- AttributeName: PK
KeyType: HASH
- AttributeName: SK
KeyType: RANGE
Common Pitfalls
1. Hot Partitions
Problem: All traffic hitting one partition key.
Solution: Add randomness or use composite keys.
// ❌ Bad: Single hot partition
PK: "GLOBAL"
SK: "USER#123"
// ✅ Good: Distributed partitions
PK: `SHARD#${userId % 10}` // 10 partitions
SK: "USER#123"
2. Scan Operations
Problem: Scanning entire table is slow and expensive.
Solution: Design access patterns with queries.
3. Large Items
Limit: 400KB per item
Solution: Store large data in S3, reference in DynamoDB.
// Store large content in S3
const s3Key = `content/${userId}/${postId}.json`;
await s3.putObject({
Bucket: BUCKET_NAME,
Key: s3Key,
Body: JSON.stringify(largeContent),
});
// Store reference in DynamoDB
await docClient.put({
TableName: TABLE_NAME,
Item: {
PK: `USER#${userId}`,
SK: `POST#${postId}`,
s3Key,
title: 'Post title',
},
});
💡Key Takeaways
- 1Single table design reduces costs and improves performance - plan access patterns first
- 2GSIs enable flexible query patterns without expensive scans
- 3Lambda cold starts are manageable with provisioned concurrency and proper optimization
- 4Connection reuse is critical for Lambda performance - initialize outside the handler
- 5Auto-scaling provisioned capacity balances cost and performance effectively
- 6Event-driven architecture with EventBridge scales effortlessly to millions of events
- 7Monitor everything with CloudWatch and X-Ray for production visibility
- 8Infrastructure as Code (Serverless Framework, SAM, or Terraform) is essential for reproducibility
Serverless with Lambda + DynamoDB can handle massive scale at low cost—if you design it right from the start.
