Serverless with AWS Lambda
Introduction
AWS Lambda lets you run code without provisioning servers. This guide covers Lambda functions, API Gateway, DynamoDB, event triggers, deployment strategies, and best practices for building production-ready serverless applications.
1. Lambda Function Basics
// Basic Lambda function structure
exports.handler = async (event, context) => {
// event: input data (API request, S3 event, etc.)
// context: runtime information
console.log('Event:', JSON.stringify(event));
console.log('Request ID:', context.requestId);
try {
// Your business logic
const result = await processData(event);
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 })
};
}
};
// TypeScript Lambda with better typing
import { APIGatewayProxyEvent, APIGatewayProxyResult, Context } from 'aws-lambda';
export const handler = async (
event: APIGatewayProxyEvent,
context: Context
): Promise => {
// Type-safe Lambda function
const { body, pathParameters, queryStringParameters } = event;
return {
statusCode: 200,
body: JSON.stringify({ message: 'Success' })
};
};
2. API Gateway Integration
// serverless.yml - Serverless Framework
service: my-api
provider:
name: aws
runtime: nodejs18.x
region: us-east-1
environment:
TABLE_NAME: ${self:service}-${self:provider.stage}
iam:
role:
statements:
- Effect: Allow
Action:
- dynamodb:Query
- dynamodb:Scan
- dynamodb:GetItem
- dynamodb:PutItem
Resource: "arn:aws:dynamodb:${self:provider.region}:*:table/${self:provider.environment.TABLE_NAME}"
functions:
getUser:
handler: src/handlers/getUser.handler
events:
- http:
path: /users/{id}
method: get
cors: true
createUser:
handler: src/handlers/createUser.handler
events:
- http:
path: /users
method: post
cors: true
request:
schemas:
application/json: ${file(schemas/user-schema.json)}
listUsers:
handler: src/handlers/listUsers.handler
events:
- http:
path: /users
method: get
cors: true
request:
parameters:
querystrings:
page: false
limit: false
resources:
Resources:
UsersTable:
Type: AWS::DynamoDB::Table
Properties:
TableName: ${self:provider.environment.TABLE_NAME}
AttributeDefinitions:
- AttributeName: id
AttributeType: S
KeySchema:
- AttributeName: id
KeyType: HASH
BillingMode: PAY_PER_REQUEST
3. DynamoDB Operations
// DynamoDB client with AWS SDK v3
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, GetCommand, PutCommand, QueryCommand, ScanCommand } from '@aws-sdk/lib-dynamodb';
const client = new DynamoDBClient({ region: 'us-east-1' });
const docClient = DynamoDBDocumentClient.from(client);
const TABLE_NAME = process.env.TABLE_NAME;
// Get single item
export async function getUser(userId: string) {
const command = new GetCommand({
TableName: TABLE_NAME,
Key: { id: userId }
});
const response = await docClient.send(command);
return response.Item;
}
// Create/Update item
export async function createUser(user: any) {
const command = new PutCommand({
TableName: TABLE_NAME,
Item: {
id: generateId(),
...user,
createdAt: new Date().toISOString()
},
ConditionExpression: 'attribute_not_exists(id)' // Prevent overwrites
});
await docClient.send(command);
}
// Query with pagination
export async function listUsersByStatus(status: string, limit = 20, lastKey?: any) {
const command = new QueryCommand({
TableName: TABLE_NAME,
IndexName: 'StatusIndex',
KeyConditionExpression: '#status = :status',
ExpressionAttributeNames: {
'#status': 'status'
},
ExpressionAttributeValues: {
':status': status
},
Limit: limit,
ExclusiveStartKey: lastKey
});
const response = await docClient.send(command);
return {
items: response.Items,
lastKey: response.LastEvaluatedKey
};
}
// Batch operations
import { BatchWriteCommand } from '@aws-sdk/lib-dynamodb';
export async function batchCreateUsers(users: any[]) {
const batches = chunk(users, 25); // DynamoDB limit
for (const batch of batches) {
const command = new BatchWriteCommand({
RequestItems: {
[TABLE_NAME]: batch.map(user => ({
PutRequest: {
Item: {
id: generateId(),
...user,
createdAt: new Date().toISOString()
}
}
}))
}
});
await docClient.send(command);
}
}
4. Lambda Handler Examples
// GET /users/{id}
import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import { getUser } from '../lib/dynamodb';
export const handler = async (event: APIGatewayProxyEvent): Promise => {
try {
const userId = event.pathParameters?.id;
if (!userId) {
return {
statusCode: 400,
body: JSON.stringify({ error: 'User ID required' })
};
}
const user = await getUser(userId);
if (!user) {
return {
statusCode: 404,
body: JSON.stringify({ error: 'User not found' })
};
}
return {
statusCode: 200,
body: JSON.stringify(user)
};
} catch (error) {
console.error('Error:', error);
return {
statusCode: 500,
body: JSON.stringify({ error: 'Internal server error' })
};
}
};
// POST /users
import { createUser } from '../lib/dynamodb';
export const handler = async (event: APIGatewayProxyEvent): Promise => {
try {
if (!event.body) {
return {
statusCode: 400,
body: JSON.stringify({ error: 'Request body required' })
};
}
const data = JSON.parse(event.body);
// Validation
if (!data.email || !data.name) {
return {
statusCode: 400,
body: JSON.stringify({ error: 'Email and name required' })
};
}
const user = await createUser(data);
return {
statusCode: 201,
body: JSON.stringify(user)
};
} catch (error) {
console.error('Error:', error);
return {
statusCode: 500,
body: JSON.stringify({ error: 'Internal server error' })
};
}
};
5. Event-Driven Architecture
// S3 trigger - Process uploaded files
export const handler = async (event: S3Event) => {
for (const record of event.Records) {
const bucket = record.s3.bucket.name;
const key = decodeURIComponent(record.s3.object.key.replace(/\+/g, ' '));
console.log(`Processing file: ${key} from bucket: ${bucket}`);
// Get file from S3
const s3 = new S3Client({});
const getCommand = new GetObjectCommand({ Bucket: bucket, Key: key });
const response = await s3.send(getCommand);
// Process file
const content = await response.Body?.transformToString();
// Save results
const putCommand = new PutObjectCommand({
Bucket: bucket,
Key: `processed/${key}`,
Body: processContent(content)
});
await s3.send(putCommand);
}
};
// SQS trigger - Process messages
export const handler = async (event: SQSEvent) => {
for (const record of event.Records) {
const message = JSON.parse(record.body);
try {
await processMessage(message);
// Message auto-deleted on success
} catch (error) {
console.error('Failed to process message:', error);
// Message returned to queue for retry
throw error;
}
}
};
// DynamoDB Stream - React to changes
export const handler = async (event: DynamoDBStreamEvent) => {
for (const record of event.Records) {
if (record.eventName === 'INSERT') {
const newUser = unmarshall(record.dynamodb!.NewImage!);
await sendWelcomeEmail(newUser.email);
} else if (record.eventName === 'MODIFY') {
const oldUser = unmarshall(record.dynamodb!.OldImage!);
const newUser = unmarshall(record.dynamodb!.NewImage!);
if (oldUser.status !== newUser.status) {
await notifyStatusChange(newUser);
}
}
}
};
// EventBridge (CloudWatch Events)
functions:
scheduledTask:
handler: src/handlers/scheduledTask.handler
events:
- schedule:
rate: cron(0 2 * * ? *) # Every day at 2 AM
enabled: true
6. Authentication & Authorization
// Lambda Authorizer (Custom)
export const handler = async (event: APIGatewayTokenAuthorizerEvent) => {
try {
const token = event.authorizationToken.replace('Bearer ', '');
// Verify JWT
const decoded = jwt.verify(token, process.env.JWT_SECRET!);
// Generate policy
return {
principalId: decoded.userId,
policyDocument: {
Version: '2012-10-17',
Statement: [
{
Action: 'execute-api:Invoke',
Effect: 'Allow',
Resource: event.methodArn
}
]
},
context: {
userId: decoded.userId,
email: decoded.email,
role: decoded.role
}
};
} catch (error) {
throw new Error('Unauthorized');
}
};
// Use authorizer in serverless.yml
functions:
protectedRoute:
handler: src/handlers/protected.handler
events:
- http:
path: /protected
method: get
authorizer:
name: customAuthorizer
type: token
identitySource: method.request.header.Authorization
// Access user info in protected function
export const handler = async (event: APIGatewayProxyEvent) => {
const userId = event.requestContext.authorizer?.userId;
const role = event.requestContext.authorizer?.role;
// Check permissions
if (role !== 'admin') {
return {
statusCode: 403,
body: JSON.stringify({ error: 'Forbidden' })
};
}
return {
statusCode: 200,
body: JSON.stringify({ message: 'Access granted', userId })
};
};
7. Cold Start Optimization
// Minimize cold starts
// 1. Provisioned Concurrency
functions:
criticalFunction:
handler: src/handlers/critical.handler
provisionedConcurrency: 2 # Keep 2 instances warm
// 2. Reuse connections outside handler
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
// Initialize outside (reused across invocations)
const client = new DynamoDBClient({
region: process.env.AWS_REGION,
maxAttempts: 3
});
export const handler = async (event: any) => {
// Use client (already initialized)
// ...
};
// 3. Reduce package size
// package.json
{
"scripts": {
"build": "esbuild src/index.ts --bundle --platform=node --target=node18 --outfile=dist/index.js"
}
}
// 4. Use Lambda Layers for dependencies
layers:
commonDeps:
path: layers/common
name: ${self:service}-common-deps
description: Common dependencies
compatibleRuntimes:
- nodejs18.x
functions:
myFunction:
handler: src/handler.main
layers:
- { Ref: CommonDepsLambdaLayer }
// 5. Lazy load heavy dependencies
export const handler = async (event: any) => {
// Only load when needed
if (event.requiresHeavyLib) {
const heavyLib = await import('heavy-library');
return heavyLib.process(event.data);
}
return simpleProcess(event.data);
};
8. Environment & Secrets
// Secrets Manager integration
import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager';
const secretsClient = new SecretsManagerClient({ region: 'us-east-1' });
// Cache secrets
let cachedSecrets: any = null;
async function getSecrets() {
if (cachedSecrets) {
return cachedSecrets;
}
const command = new GetSecretValueCommand({
SecretId: process.env.SECRET_NAME
});
const response = await secretsClient.send(command);
cachedSecrets = JSON.parse(response.SecretString!);
return cachedSecrets;
}
export const handler = async (event: any) => {
const secrets = await getSecrets();
// Use secrets
const apiKey = secrets.API_KEY;
const dbPassword = secrets.DB_PASSWORD;
// ...
};
// Parameter Store (cheaper for simple values)
import { SSMClient, GetParameterCommand } from '@aws-sdk/client-ssm';
const ssmClient = new SSMClient({ region: 'us-east-1' });
async function getParameter(name: string) {
const command = new GetParameterCommand({
Name: name,
WithDecryption: true
});
const response = await ssmClient.send(command);
return response.Parameter?.Value;
}
9. Monitoring & Logging
// Structured logging with Lambda Powertools
import { Logger } from '@aws-lambda-powertools/logger';
const logger = new Logger({
serviceName: 'my-service',
logLevel: 'INFO'
});
export const handler = async (event: any) => {
logger.addContext({
requestId: event.requestContext?.requestId
});
logger.info('Processing request', {
userId: event.pathParameters?.userId,
action: 'get-user'
});
try {
const result = await processRequest(event);
logger.info('Request completed', {
duration: Date.now() - startTime,
itemCount: result.items.length
});
return result;
} catch (error) {
logger.error('Request failed', {
error: error.message,
stack: error.stack
});
throw error;
}
};
// Custom metrics with CloudWatch
import { CloudWatchClient, PutMetricDataCommand } from '@aws-sdk/client-cloudwatch';
async function recordMetric(name: string, value: number) {
const client = new CloudWatchClient({});
const command = new PutMetricDataCommand({
Namespace: 'MyApp',
MetricData: [
{
MetricName: name,
Value: value,
Unit: 'Count',
Timestamp: new Date()
}
]
});
await client.send(command);
}
// X-Ray tracing
import { captureAWSv3Client } from 'aws-xray-sdk-core';
const client = captureAWSv3Client(new DynamoDBClient({}));
10. Deployment & CI/CD
# GitHub Actions deployment
name: Deploy Lambda
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v2
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-east-1
- name: Deploy with Serverless Framework
run: npx serverless deploy --stage production
# Blue-Green deployment
functions:
myFunction:
handler: src/handler.main
deploymentSettings:
type: AllAtOnce # or Linear10PercentEvery1Minute, Canary10Percent5Minutes
alias: Live
# Local testing with SAM
sam local start-api
sam local invoke MyFunction -e events/test-event.json
11. Best Practices
✓ Serverless Best Practices:
- Keep functions small and focused (single responsibility)
- Use environment variables for configuration
- Implement proper error handling and retries
- Set appropriate timeout values (don't use max)
- Use Lambda Layers for shared dependencies
- Enable X-Ray tracing for debugging
- Implement structured logging
- Use Provisioned Concurrency for critical paths
- Minimize package size (tree shaking, bundling)
- Cache expensive operations (DB connections, secrets)
- Use DynamoDB on-demand billing for variable traffic
- Implement idempotency for critical operations
- Set up CloudWatch alarms for errors and throttles
- Use Lambda Power Tuning to optimize memory
- Implement proper IAM roles (least privilege)
Conclusion
AWS Lambda enables building scalable applications without server management. Success requires understanding cold starts, optimizing costs, proper monitoring, and following serverless best practices. Start with simple functions, measure performance, and optimize based on real usage patterns.
💡 Pro Tip: Use AWS Lambda Power Tuning to find the optimal memory configuration for your functions. More memory = faster CPU, but costs more. The tool helps you find the sweet spot between cost and performance.