Serverless Architecture Patterns: Building Scalable Apps Without Managing Servers
Serverless gets marketed as “never think about infrastructure again!” which is complete nonsense. You absolutely think about infrastructure—you just think about it differently. And when you get it right, serverless is incredible. When you get it wrong, you’ll wish you’d just deployed a simple Node.js server.
I’ve built serverless apps handling millions of requests daily, and I’ve debugged nightmarish cold start issues at 2 AM. Let me show you what actually works.
What Serverless Actually Means
Serverless doesn’t mean “no servers.” It means:
- You don’t provision or manage servers
- You pay only for what you use
- Scaling is automatic
- You write functions, not long-running processes
The Good:
- Zero idle cost (pay per request)
- Infinite scalability (theoretically)
- No server maintenance
- Fast iteration
The Bad:
- Cold starts (sometimes)
- Vendor lock-in
- Debugging is harder
- Cost can spiral with high traffic
When Serverless Makes Sense
Perfect Use Cases
APIs with Variable Traffic:
Traffic: 100 req/day → 10,000 req/day → 100 req/day
Traditional: Pay for peak capacity 24/7
Serverless: Pay only for actual requests
Event-Driven Workflows:
- Image processing on upload
- Email sending on user signup
- Data processing on S3 file upload
- Scheduled tasks (cron jobs)
Microservices with Clear Boundaries:
- Authentication service
- Payment processing
- Notification delivery
- Report generation
Bad Use Cases
Long-Running Processes: Lambda has a 15-minute max execution. Don’t use it for:
- Video encoding (use ECS/Batch)
- Large data migrations
- Machine learning training
Consistent High Traffic: If you’re handling 10,000 req/sec 24/7, traditional servers are cheaper. Do the math.
Low Latency Requirements: Cold starts can add 1-3 seconds. If every millisecond counts, serverless might not work.
Core AWS Serverless Services
Lambda: The Foundation
// Basic Lambda handler
export const handler = async (event, context) => {
try {
const body = JSON.parse(event.body);
// Your business logic
const result = await processData(body);
return {
statusCode: 200,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
},
body: JSON.stringify(result),
};
} catch (error) {
console.error('Error:', error);
return {
statusCode: 500,
body: JSON.stringify({ error: error.message }),
};
}
};
Key Limits:
- Max execution time: 15 minutes
- Max memory: 10GB
- Max deployment package: 250MB (unzipped)
- Max concurrent executions: 1000 (default, can increase)
API Gateway: The HTTP Layer
# Serverless Framework configuration
service: my-api
provider:
name: aws
runtime: nodejs20.x
region: us-east-1
functions:
getUser:
handler: src/handlers/users.get
events:
- http:
path: users/{id}
method: get
cors: true
createUser:
handler: src/handlers/users.create
events:
- http:
path: users
method: post
cors: true
authorizer:
name: authorizer
resultTtlInSeconds: 300
authorizer:
handler: src/handlers/auth.verify
DynamoDB: The Database
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, GetCommand, PutCommand } from '@aws-sdk/lib-dynamodb';
const client = new DynamoDBClient({});
const docClient = DynamoDBDocumentClient.from(client);
// Get item
export const getUser = async (userId) => {
const command = new GetCommand({
TableName: 'Users',
Key: { userId },
});
const { Item } = await docClient.send(command);
return Item;
};
// Put item
export const createUser = async (user) => {
const command = new PutCommand({
TableName: 'Users',
Item: {
userId: user.id,
email: user.email,
createdAt: new Date().toISOString(),
...user,
},
});
await docClient.send(command);
return user;
};
// Query with index
export const getUserByEmail = async (email) => {
const command = new QueryCommand({
TableName: 'Users',
IndexName: 'EmailIndex',
KeyConditionExpression: 'email = :email',
ExpressionAttributeValues: {
':email': email,
},
});
const { Items } = await docClient.send(command);
return Items[0];
};
DynamoDB Best Practices:
- Design for access patterns, not normalization
- Use single-table design for related entities
- Provision indexes carefully (they cost money)
- Use batch operations when possible
Architectural Patterns
Pattern 1: API Backend
Classic REST API built with Lambda + API Gateway:
API Gateway
├── GET /users → Lambda: getUsers → DynamoDB
├── POST /users → Lambda: createUser → DynamoDB
├── GET /users/{id} → Lambda: getUser → DynamoDB
└── PUT /users/{id} → Lambda: updateUser → DynamoDB
Implementation:
// src/handlers/users.js
import { getUser, createUser, updateUser, listUsers } from '../services/userService';
export const get = async (event) => {
const { id } = event.pathParameters;
const user = await getUser(id);
if (!user) {
return {
statusCode: 404,
body: JSON.stringify({ error: 'User not found' }),
};
}
return {
statusCode: 200,
body: JSON.stringify(user),
};
};
export const create = async (event) => {
const data = JSON.parse(event.body);
const user = await createUser(data);
return {
statusCode: 201,
body: JSON.stringify(user),
};
};
export const list = async (event) => {
const { limit = 20, nextToken } = event.queryStringParameters || {};
const result = await listUsers(limit, nextToken);
return {
statusCode: 200,
body: JSON.stringify(result),
};
};
Pattern 2: Event-Driven Processing
S3 uploads trigger Lambda processing:
functions:
processImage:
handler: src/handlers/images.process
events:
- s3:
bucket: uploaded-images
event: s3:ObjectCreated:*
rules:
- prefix: uploads/
- suffix: .jpg
Handler:
import sharp from 'sharp';
import { S3Client, GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
const s3 = new S3Client({});
export const process = async (event) => {
for (const record of event.Records) {
const bucket = record.s3.bucket.name;
const key = record.s3.object.key;
// Get original image
const getCommand = new GetObjectCommand({ Bucket: bucket, Key: key });
const { Body } = await s3.send(getCommand);
// Process image
const thumbnail = await sharp(Body)
.resize(200, 200, { fit: 'cover' })
.jpeg({ quality: 80 })
.toBuffer();
// Save thumbnail
const putCommand = new PutObjectCommand({
Bucket: bucket,
Key: key.replace('uploads/', 'thumbnails/'),
Body: thumbnail,
ContentType: 'image/jpeg',
});
await s3.send(putCommand);
}
};
Pattern 3: Queue-Based Processing
SQS ensures reliable async processing:
functions:
sendEmail:
handler: src/handlers/email.send
events:
- sqs:
arn: !GetAtt EmailQueue.Arn
batchSize: 10
resources:
Resources:
EmailQueue:
Type: AWS::SQS::Queue
Properties:
QueueName: EmailQueue
VisibilityTimeout: 300 # 5 minutes
RedrivePolicy:
deadLetterTargetArn: !GetAtt EmailDLQ.Arn
maxReceiveCount: 3
EmailDLQ:
Type: AWS::SQS::Queue
Properties:
QueueName: EmailDLQ
Producer (adds to queue):
import { SQSClient, SendMessageCommand } from '@aws-sdk/client-sqs';
const sqs = new SQSClient({});
export const queueEmail = async (to, subject, body) => {
const command = new SendMessageCommand({
QueueUrl: process.env.EMAIL_QUEUE_URL,
MessageBody: JSON.stringify({ to, subject, body }),
});
await sqs.send(command);
};
Consumer (processes queue):
export const send = async (event) => {
for (const record of event.Records) {
const { to, subject, body } = JSON.parse(record.body);
try {
await sendEmail(to, subject, body);
} catch (error) {
console.error('Failed to send email:', error);
throw error; // Message goes to DLQ after retries
}
}
};
Pattern 4: Step Functions for Workflows
Complex workflows with state management:
stepFunctions:
stateMachines:
orderProcessing:
definition:
StartAt: ValidateOrder
States:
ValidateOrder:
Type: Task
Resource: !GetAtt ValidateOrderFunction.Arn
Next: CheckInventory
CheckInventory:
Type: Task
Resource: !GetAtt CheckInventoryFunction.Arn
Next: ChargePayment
Catch:
- ErrorEquals: [OutOfStock]
Next: NotifyOutOfStock
ChargePayment:
Type: Task
Resource: !GetAtt ChargePaymentFunction.Arn
Next: FulfillOrder
Catch:
- ErrorEquals: [PaymentFailed]
Next: NotifyPaymentFailed
FulfillOrder:
Type: Task
Resource: !GetAtt FulfillOrderFunction.Arn
End: true
NotifyOutOfStock:
Type: Task
Resource: !GetAtt NotifyFunction.Arn
End: true
NotifyPaymentFailed:
Type: Task
Resource: !GetAtt NotifyFunction.Arn
End: true
Start execution:
import { SFNClient, StartExecutionCommand } from '@aws-sdk/client-sfn';
const sfn = new SFNClient({});
export const startOrderProcessing = async (orderId) => {
const command = new StartExecutionCommand({
stateMachineArn: process.env.STATE_MACHINE_ARN,
input: JSON.stringify({ orderId }),
});
const { executionArn } = await sfn.send(command);
return executionArn;
};
Optimizing Cold Starts
Cold starts are the #1 serverless complaint. Here’s how to minimize them:
Keep Functions Small
// Bad - huge bundle
import _ from 'lodash'; // 70KB
import moment from 'moment'; // 67KB
import axios from 'axios'; // 13KB
// Good - minimal dependencies
import { chunk } from 'lodash-es/chunk'; // Tree-shakeable
import { parseISO } from 'date-fns'; // Small, modular
// Or just use native JS
const chunk = (arr, size) => Array.from(
{ length: Math.ceil(arr.length / size) },
(_, i) => arr.slice(i * size, i * size + size)
);
Use Provisioned Concurrency
functions:
api:
handler: handler.api
provisionedConcurrency: 5 # Always keep 5 warm
Cost: You pay for provisioned instances 24/7, but they’re always warm.
ARM Processors (Graviton)
provider:
name: aws
runtime: nodejs20.x
architecture: arm64 # 20% cheaper, often faster
Connection Pooling
// Bad - creates new DB connection per invocation
export const handler = async (event) => {
const db = await createConnection(); // Cold start penalty
const result = await db.query('SELECT * FROM users');
return result;
};
// Good - reuse connection across invocations
let db;
export const handler = async (event) => {
if (!db) {
db = await createConnection();
}
const result = await db.query('SELECT * FROM users');
return result;
};
Cost Optimization
Right-Size Memory
Lambda CPU scales with memory. More memory = faster execution = lower cost (sometimes).
Test different memory settings:
functions:
process:
handler: handler.process
memorySize: 1024 # Start here, then test 512, 2048, 3008
I’ve seen 2048MB be cheaper than 1024MB because execution time halved.
Use Lambda Layers
Share code between functions without duplicating:
layers:
commonDeps:
path: layers/common
compatibleRuntimes:
- nodejs20.x
functions:
func1:
handler: handler1.main
layers:
- { Ref: CommonDepsLambdaLayer }
func2:
handler: handler2.main
layers:
- { Ref: CommonDepsLambdaLayer }
layers/common/nodejs/package.json:
{
"dependencies": {
"aws-sdk": "^3.0.0",
"lodash": "^4.17.21"
}
}
Batch Operations
// Bad - one Lambda per item
for (const item of items) {
await lambda.invoke({
FunctionName: 'process',
Payload: JSON.stringify(item),
});
}
// Good - batch items
const batches = chunk(items, 100);
for (const batch of batches) {
await lambda.invoke({
FunctionName: 'processBatch',
Payload: JSON.stringify(batch),
});
}
Monitoring and Debugging
CloudWatch Logs
export const handler = async (event) => {
console.log('Event:', JSON.stringify(event, null, 2));
try {
const result = await processEvent(event);
console.log('Success:', result);
return result;
} catch (error) {
console.error('Error:', error);
throw error;
}
};
Structured Logging:
const log = (level, message, data = {}) => {
console.log(JSON.stringify({
level,
message,
timestamp: new Date().toISOString(),
requestId: context.requestId,
...data,
}));
};
export const handler = async (event, context) => {
log('info', 'Processing event', { eventType: event.type });
try {
const result = await process(event);
log('info', 'Success', { result });
return result;
} catch (error) {
log('error', 'Failed', { error: error.message, stack: error.stack });
throw error;
}
};
X-Ray Tracing
import AWSXRay from 'aws-xray-sdk-core';
import AWS from 'aws-sdk';
const dynamodb = AWSXRay.captureAWSClient(new AWS.DynamoDB.DocumentClient());
export const handler = async (event) => {
// Automatically traced
const user = await dynamodb.get({
TableName: 'Users',
Key: { id: event.userId },
}).promise();
return user;
};
Lambda Insights
functions:
api:
handler: handler.api
layers:
- arn:aws:lambda:us-east-1:580247275435:layer:LambdaInsightsExtension:21
environment:
LAMBDA_INSIGHTS_LOG_LEVEL: info
Provides memory, CPU, network metrics.
Testing Serverless
Local Testing
// tests/users.test.js
import { handler } from '../src/handlers/users';
describe('GET /users/{id}', () => {
it('returns user', async () => {
const event = {
pathParameters: { id: '123' },
httpMethod: 'GET',
};
const response = await handler(event);
expect(response.statusCode).toBe(200);
expect(JSON.parse(response.body)).toMatchObject({
id: '123',
email: 'test@example.com',
});
});
it('returns 404 for missing user', async () => {
const event = {
pathParameters: { id: 'nonexistent' },
httpMethod: 'GET',
};
const response = await handler(event);
expect(response.statusCode).toBe(404);
});
});
Integration Testing
# serverless-offline for local API
npm install --save-dev serverless-offline
# serverless.yml
plugins:
- serverless-offline
# Run locally
npx serverless offline
Load Testing
import { Lambda } from '@aws-sdk/client-lambda';
const lambda = new Lambda({});
async function loadTest() {
const promises = [];
for (let i = 0; i < 1000; i++) {
promises.push(
lambda.invoke({
FunctionName: 'my-function',
Payload: JSON.stringify({ test: i }),
})
);
}
const results = await Promise.all(promises);
console.log('Success:', results.filter(r => !r.FunctionError).length);
}
loadTest();
Common Pitfalls
Ignoring Concurrency Limits
Default limit is 1000 concurrent executions per region. Sudden traffic spike? Throttled.
Solution: Request limit increase or use reserved concurrency.
Not Handling Timeouts
Lambda times out at max 15 minutes. Long process? It will fail.
Solution: Break into smaller chunks or use Step Functions.
Storing State in /tmp
/tmp survives across invocations (maybe), but isn’t guaranteed.
Solution: Use S3, DynamoDB, or ElastiCache for state.
Circular Dependencies
Lambda → DynamoDB → DynamoDB Stream → Lambda → DynamoDB
This creates infinite loops. Been there, AWS bill was $10k.
Solution: Add guards, use TTLs, monitor recursion.
Conclusion
Serverless isn’t a silver bullet. It’s a powerful tool that works brilliantly for certain workloads and fails miserably for others.
Use serverless when:
- Traffic is variable or unpredictable
- You want to focus on code, not infrastructure
- You need instant scalability
- Cost optimization matters (low/variable traffic)
Avoid serverless when:
- You have consistent high traffic (cheaper to use containers)
- You need sub-100ms latency consistently
- You have long-running processes (>15 minutes)
- Your team lacks cloud experience
Start small. Build one serverless function. Learn the patterns. Understand the costs. Then decide if it’s right for your use case.
Considering serverless for your next project? VooStack has extensive experience building and optimizing serverless applications on AWS. Let’s discuss your architecture.