Serverless Architecture with AWS Lambda: Advanced Patterns
Explore advanced AWS Lambda patterns including cold start optimization, connection pooling, middleware patterns, and event-driven architectures.
Dibyank Padhy
Engineering Manager & Full Stack Developer
Serverless Architecture with AWS Lambda: Advanced Patterns
AWS Lambda has revolutionized how we build and deploy applications. In this deep dive, we'll explore advanced patterns that go beyond basic function handlers to build production-ready serverless architectures.
Cold Start Optimization
Cold starts are the primary concern in Lambda performance. Here are proven strategies to minimize their impact:
// 1. Move initialization outside the handler
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
// Initialized once during cold start, reused across invocations
const ddbClient = new DynamoDBClient({});
const docClient = DynamoDBDocumentClient.from(ddbClient, {
marshallOptions: { removeUndefinedValues: true },
});
// Pre-warm connections
let isWarmed = false;
async function warmUp() {
if (isWarmed) return;
// Make a lightweight call to establish connection
try {
await docClient.send(new DescribeTableCommand({ TableName: process.env.TABLE_NAME }));
} catch (e) {
// Ignore errors during warm-up
}
isWarmed = true;
}
export const handler = async (event: APIGatewayEvent) => {
await warmUp();
// Your handler logic
};2. Provisioned Concurrency Configuration
# serverless.yml
functions:
api:
handler: src/handlers/api.handler
provisionedConcurrency: 5 # Keep 5 instances warm
events:
- http:
path: /api/{proxy+}
method: any
# Or use Application Auto Scaling for dynamic provisioned concurrency
resources:
Resources:
ApiLambdaProvisionedConcurrencyAutoScaling:
Type: AWS::ApplicationAutoScaling::ScalableTarget
Properties:
MaxCapacity: 50
MinCapacity: 5
ResourceId: !Sub function:${ApiLambdaFunction}:live
RoleARN: !GetAtt AutoScalingRole.Arn
ScalableDimension: lambda:function:ProvisionedConcurrency
ServiceNamespace: lambda
ApiLambdaAutoScalingPolicy:
Type: AWS::ApplicationAutoScaling::ScalingPolicy
Properties:
PolicyName: ApiLambdaAutoScalingPolicy
PolicyType: TargetTrackingScaling
ScalingTargetId: !Ref ApiLambdaProvisionedConcurrencyAutoScaling
TargetTrackingScalingPolicyConfiguration:
TargetValue: 0.7 # Scale when 70% utilized
PredefinedMetricSpecification:
PredefinedMetricType: LambdaProvisionedConcurrencyUtilizationMiddleware Pattern
Implement a clean middleware pattern for Lambda handlers:
type LambdaContext = {
event: any;
context: Context;
response?: any;
error?: Error;
state: Record<string, any>;
};
type Middleware = (
ctx: LambdaContext,
next: () => Promise<void>
) => Promise<void>;
function compose(middlewares: Middleware[]) {
return async (ctx: LambdaContext) => {
let index = -1;
async function dispatch(i: number): Promise<void> {
if (i <= index) {
throw new Error('next() called multiple times');
}
index = i;
const fn = middlewares[i];
if (!fn) return;
await fn(ctx, () => dispatch(i + 1));
}
await dispatch(0);
};
}
// Middleware implementations
const errorHandler: Middleware = async (ctx, next) => {
try {
await next();
} catch (error) {
ctx.error = error as Error;
ctx.response = {
statusCode: 500,
body: JSON.stringify({
error: 'Internal Server Error',
requestId: ctx.context.awsRequestId,
}),
};
// Log error with context
console.error({
error: (error as Error).message,
stack: (error as Error).stack,
event: ctx.event,
requestId: ctx.context.awsRequestId,
});
}
};
const jsonParser: Middleware = async (ctx, next) => {
if (ctx.event.body) {
try {
ctx.state.body = JSON.parse(ctx.event.body);
} catch {
ctx.response = {
statusCode: 400,
body: JSON.stringify({ error: 'Invalid JSON' }),
};
return;
}
}
await next();
};
const authMiddleware: Middleware = async (ctx, next) => {
const token = ctx.event.headers?.Authorization?.replace('Bearer ', '');
if (!token) {
ctx.response = {
statusCode: 401,
body: JSON.stringify({ error: 'Unauthorized' }),
};
return;
}
try {
ctx.state.user = await verifyToken(token);
await next();
} catch {
ctx.response = {
statusCode: 401,
body: JSON.stringify({ error: 'Invalid token' }),
};
}
};
const responseFormatter: Middleware = async (ctx, next) => {
await next();
if (ctx.response && !ctx.response.headers) {
ctx.response.headers = {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
};
}
};
// Create handler with middleware
function createHandler(
middlewares: Middleware[],
handler: (ctx: LambdaContext) => Promise<void>
) {
const chain = compose([...middlewares, handler]);
return async (event: any, context: Context) => {
const ctx: LambdaContext = { event, context, state: {} };
await chain(ctx);
return ctx.response;
};
}
// Usage
export const handler = createHandler(
[errorHandler, responseFormatter, jsonParser, authMiddleware],
async (ctx) => {
const { user } = ctx.state;
const result = await getUserData(user.id);
ctx.response = {
statusCode: 200,
body: JSON.stringify(result),
};
}
);Connection Pooling with RDS Proxy
Lambda functions can exhaust database connections. Use RDS Proxy or implement connection pooling:
import { Pool, PoolConfig } from 'pg';
import { Signer } from '@aws-sdk/rds-signer';
// Connection pool - initialized once per container
let pool: Pool | null = null;
async function getPool(): Promise<Pool> {
if (pool) return pool;
const signer = new Signer({
hostname: process.env.DB_HOST!,
port: 5432,
username: process.env.DB_USER!,
region: process.env.AWS_REGION,
});
// Generate IAM auth token
const token = await signer.getAuthToken();
const config: PoolConfig = {
host: process.env.DB_HOST,
port: 5432,
user: process.env.DB_USER,
password: token,
database: process.env.DB_NAME,
ssl: { rejectUnauthorized: false },
max: 1, // Lambda best practice: 1 connection per container
idleTimeoutMillis: 120000,
connectionTimeoutMillis: 5000,
};
pool = new Pool(config);
// Handle pool errors
pool.on('error', (err) => {
console.error('Unexpected pool error:', err);
pool = null; // Reset pool on error
});
return pool;
}
// Wrapper for database operations with retry
async function query<T>(
sql: string,
params?: any[],
retries = 3
): Promise<T[]> {
const db = await getPool();
for (let attempt = 1; attempt <= retries; attempt++) {
try {
const result = await db.query(sql, params);
return result.rows;
} catch (error: any) {
if (error.code === 'ECONNREFUSED' || error.code === '57P01') {
// Connection error - reset pool and retry
pool = null;
if (attempt === retries) throw error;
await new Promise(r => setTimeout(r, 100 * attempt));
continue;
}
throw error;
}
}
throw new Error('Query failed after retries');
}
// Clean up on container shutdown
process.on('beforeExit', async () => {
if (pool) {
await pool.end();
}
});Event-Driven Patterns with EventBridge
Build loosely coupled services using EventBridge:
import {
EventBridgeClient,
PutEventsCommand
} from '@aws-sdk/client-eventbridge';
const eventBridge = new EventBridgeClient({});
interface DomainEvent<T> {
source: string;
detailType: string;
detail: T;
correlationId?: string;
}
// Event publisher
async function publishEvent<T>(event: DomainEvent<T>): Promise<void> {
const command = new PutEventsCommand({
Entries: [{
Source: event.source,
DetailType: event.detailType,
Detail: JSON.stringify({
...event.detail,
metadata: {
correlationId: event.correlationId || crypto.randomUUID(),
timestamp: new Date().toISOString(),
version: '1.0',
},
}),
EventBusName: process.env.EVENT_BUS_NAME,
}],
});
const response = await eventBridge.send(command);
if (response.FailedEntryCount && response.FailedEntryCount > 0) {
throw new Error(`Failed to publish event: ${response.Entries?.[0]?.ErrorMessage}`);
}
}
// Example: Order service publishes events
export async function createOrder(orderData: CreateOrderDTO) {
const order = await saveOrder(orderData);
// Publish domain event
await publishEvent({
source: 'orders.service',
detailType: 'OrderCreated',
detail: {
orderId: order.id,
customerId: order.customerId,
items: order.items,
total: order.total,
},
});
return order;
}
// Event handler Lambda
export const orderCreatedHandler = async (event: EventBridgeEvent<'OrderCreated', OrderDetail>) => {
const { detail } = event;
const correlationId = detail.metadata?.correlationId;
console.log(`Processing OrderCreated event: ${detail.orderId}`, { correlationId });
// Process the event
await sendOrderConfirmationEmail(detail);
await updateInventory(detail.items);
await notifyShipping(detail);
};Lambda Layers for Shared Code
Organize shared code efficiently using Lambda Layers:
# Directory structure
layers/
common/
nodejs/
node_modules/
package.json
utils/
index.ts
logger.ts
metrics.ts
# serverless.yml
layers:
common:
path: layers/common
name: ${self:service}-common-${sls:stage}
description: Common utilities and dependencies
compatibleRuntimes:
- nodejs18.x
- nodejs20.x
retain: false
functions:
api:
handler: src/handlers/api.handler
layers:
- !Ref CommonLambdaLayer
environment:
NODE_PATH: /opt/nodejs/node_modules// layers/common/nodejs/utils/logger.ts
import { Context } from 'aws-lambda';
interface LogContext {
requestId: string;
functionName: string;
correlationId?: string;
[key: string]: any;
}
let logContext: LogContext | null = null;
export function initLogger(context: Context, correlationId?: string) {
logContext = {
requestId: context.awsRequestId,
functionName: context.functionName,
correlationId,
};
}
function formatLog(level: string, message: string, data?: Record<string, any>) {
return JSON.stringify({
timestamp: new Date().toISOString(),
level,
message,
...logContext,
...data,
});
}
export const logger = {
info: (message: string, data?: Record<string, any>) =>
console.log(formatLog('INFO', message, data)),
warn: (message: string, data?: Record<string, any>) =>
console.warn(formatLog('WARN', message, data)),
error: (message: string, error?: Error, data?: Record<string, any>) =>
console.error(formatLog('ERROR', message, {
...data,
error: error?.message,
stack: error?.stack,
})),
debug: (message: string, data?: Record<string, any>) => {
if (process.env.LOG_LEVEL === 'DEBUG') {
console.log(formatLog('DEBUG', message, data));
}
},
};Conclusion
AWS Lambda provides incredible flexibility for building serverless applications. By applying these advanced patterns - middleware composition, connection pooling, event-driven architecture, and proper code organization - you can build maintainable, scalable, and cost-effective serverless systems.
Remember: the key to successful Lambda development is understanding the execution model and optimizing for both cold starts and warm invocations.