
Introduction
Building and managing servers manually is increasingly becoming unnecessary overhead for modern development teams. Developers today want to focus on writing code and delivering features—not on provisioning, patching, scaling, or managing infrastructure. That’s exactly what serverless computing offers. With AWS Lambda and API Gateway, you can build and deploy full-featured backends that scale automatically from zero to millions of requests and only charge you for actual usage.
In this comprehensive guide, you’ll learn how serverless architecture works, how Lambda and API Gateway integrate to create scalable APIs, deployment strategies using the Serverless Framework and AWS SAM, and production best practices for building resilient serverless applications.
What Is Serverless Computing?
Serverless doesn’t mean there are no servers—it means you don’t have to manage them. AWS handles provisioning, scaling, patching, and maintenance automatically. You deploy small, focused functions that run when triggered by events.
Benefits of Serverless Architecture
- No server management: AWS handles all infrastructure operations.
- Automatic scaling: Functions scale from zero to thousands concurrently.
- Pay-per-use pricing: Charged only when functions execute (millisecond billing).
- Fast development: Deploy code directly without container or VM management.
- Built-in high availability: Functions run across multiple availability zones automatically.
- Reduced operational burden: No OS updates, security patches, or capacity planning.
AWS Lambda Deep Dive
AWS Lambda executes code in response to events—API calls, file uploads, database changes, scheduled tasks, or queue messages. You write your logic as a function, and AWS runs it automatically when triggered.
Lambda Function Structure
# handler.py - Python Lambda Function
import json
import logging
import os
from typing import Any, Dict
# Configure logging
logger = logging.getLogger()
logger.setLevel(logging.INFO)
def handler(event: Dict[str, Any], context) -> Dict[str, Any]:
"""
Lambda handler function.
Args:
event: Event data from the trigger (API Gateway, S3, etc.)
context: Runtime information (request ID, remaining time, etc.)
Returns:
Response dict with statusCode and body
"""
logger.info(f"Request ID: {context.aws_request_id}")
logger.info(f"Event: {json.dumps(event)}")
try:
# Parse request body for POST/PUT
body = {}
if event.get('body'):
body = json.loads(event['body'])
# Access query parameters
query_params = event.get('queryStringParameters') or {}
name = query_params.get('name', body.get('name', 'Guest'))
# Access path parameters
path_params = event.get('pathParameters') or {}
user_id = path_params.get('userId')
# Access environment variables
stage = os.environ.get('STAGE', 'dev')
response_body = {
'message': f'Hello, {name}!',
'stage': stage,
'requestId': context.aws_request_id,
}
if user_id:
response_body['userId'] = user_id
return {
'statusCode': 200,
'headers': {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'Content-Type,Authorization',
},
'body': json.dumps(response_body)
}
except Exception as e:
logger.error(f"Error: {str(e)}")
return {
'statusCode': 500,
'headers': {'Content-Type': 'application/json'},
'body': json.dumps({'error': 'Internal server error'})
}
Node.js Lambda Function
// handler.js - Node.js Lambda Function
const AWS = require('aws-sdk');
// Initialize AWS SDK clients outside handler for connection reuse
const dynamodb = new AWS.DynamoDB.DocumentClient();
const TABLE_NAME = process.env.TABLE_NAME;
exports.handler = async (event, context) => {
console.log('Request ID:', context.awsRequestId);
console.log('Event:', JSON.stringify(event, null, 2));
const headers = {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'Content-Type,Authorization',
};
try {
const httpMethod = event.httpMethod;
const pathParameters = event.pathParameters || {};
const body = event.body ? JSON.parse(event.body) : {};
switch (httpMethod) {
case 'GET':
if (pathParameters.id) {
return await getItem(pathParameters.id, headers);
}
return await listItems(headers);
case 'POST':
return await createItem(body, headers);
case 'PUT':
return await updateItem(pathParameters.id, body, headers);
case 'DELETE':
return await deleteItem(pathParameters.id, headers);
default:
return {
statusCode: 405,
headers,
body: JSON.stringify({ error: 'Method not allowed' }),
};
}
} catch (error) {
console.error('Error:', error);
return {
statusCode: 500,
headers,
body: JSON.stringify({ error: 'Internal server error' }),
};
}
};
async function getItem(id, headers) {
const result = await dynamodb.get({
TableName: TABLE_NAME,
Key: { id },
}).promise();
if (!result.Item) {
return {
statusCode: 404,
headers,
body: JSON.stringify({ error: 'Item not found' }),
};
}
return {
statusCode: 200,
headers,
body: JSON.stringify(result.Item),
};
}
async function listItems(headers) {
const result = await dynamodb.scan({
TableName: TABLE_NAME,
Limit: 100,
}).promise();
return {
statusCode: 200,
headers,
body: JSON.stringify({ items: result.Items }),
};
}
async function createItem(data, headers) {
const item = {
id: Date.now().toString(),
...data,
createdAt: new Date().toISOString(),
};
await dynamodb.put({
TableName: TABLE_NAME,
Item: item,
}).promise();
return {
statusCode: 201,
headers,
body: JSON.stringify(item),
};
}
async function updateItem(id, data, headers) {
const result = await dynamodb.update({
TableName: TABLE_NAME,
Key: { id },
UpdateExpression: 'SET #data = :data, updatedAt = :updatedAt',
ExpressionAttributeNames: { '#data': 'data' },
ExpressionAttributeValues: {
':data': data,
':updatedAt': new Date().toISOString(),
},
ReturnValues: 'ALL_NEW',
}).promise();
return {
statusCode: 200,
headers,
body: JSON.stringify(result.Attributes),
};
}
async function deleteItem(id, headers) {
await dynamodb.delete({
TableName: TABLE_NAME,
Key: { id },
}).promise();
return {
statusCode: 204,
headers,
body: '',
};
}
API Gateway Configuration
API Gateway acts as the front door to your Lambda functions. It receives HTTP requests, validates them, transforms data, and triggers your functions.
API Gateway Types
- HTTP API: Lower latency, lower cost, simpler—ideal for most use cases.
- REST API: Full-featured with request validation, transformations, and API keys.
- WebSocket API: Real-time bidirectional communication.
Deploying with Serverless Framework
The Serverless Framework simplifies Lambda deployment with infrastructure-as-code.
# serverless.yml - Complete API Configuration
service: my-serverless-api
frameworkVersion: '3'
provider:
name: aws
runtime: nodejs18.x
stage: ${opt:stage, 'dev'}
region: ${opt:region, 'us-east-1'}
# Environment variables available to all functions
environment:
TABLE_NAME: ${self:service}-${self:provider.stage}
STAGE: ${self:provider.stage}
# IAM permissions for Lambda functions
iam:
role:
statements:
- Effect: Allow
Action:
- dynamodb:GetItem
- dynamodb:PutItem
- dynamodb:UpdateItem
- dynamodb:DeleteItem
- dynamodb:Scan
- dynamodb:Query
Resource:
- !GetAtt ItemsTable.Arn
- !Sub '${ItemsTable.Arn}/index/*'
# API Gateway configuration
httpApi:
cors:
allowedOrigins:
- https://myapp.com
- http://localhost:3000
allowedHeaders:
- Content-Type
- Authorization
allowedMethods:
- GET
- POST
- PUT
- DELETE
- OPTIONS
functions:
# GET /items and GET /items/{id}
getItems:
handler: handlers/items.get
events:
- httpApi:
path: /items
method: get
- httpApi:
path: /items/{id}
method: get
memorySize: 256
timeout: 10
# POST /items
createItem:
handler: handlers/items.create
events:
- httpApi:
path: /items
method: post
memorySize: 256
timeout: 10
# PUT /items/{id}
updateItem:
handler: handlers/items.update
events:
- httpApi:
path: /items/{id}
method: put
memorySize: 256
timeout: 10
# DELETE /items/{id}
deleteItem:
handler: handlers/items.delete
events:
- httpApi:
path: /items/{id}
method: delete
memorySize: 256
timeout: 10
# Scheduled function (cron job)
cleanupOldItems:
handler: handlers/cleanup.handler
events:
- schedule:
rate: rate(1 day)
enabled: true
memorySize: 512
timeout: 300
resources:
Resources:
# DynamoDB Table
ItemsTable:
Type: AWS::DynamoDB::Table
Properties:
TableName: ${self:service}-${self:provider.stage}
BillingMode: PAY_PER_REQUEST
AttributeDefinitions:
- AttributeName: id
AttributeType: S
- AttributeName: userId
AttributeType: S
- AttributeName: createdAt
AttributeType: S
KeySchema:
- AttributeName: id
KeyType: HASH
GlobalSecondaryIndexes:
- IndexName: userId-createdAt-index
KeySchema:
- AttributeName: userId
KeyType: HASH
- AttributeName: createdAt
KeyType: RANGE
Projection:
ProjectionType: ALL
plugins:
- serverless-offline
- serverless-prune-plugin
custom:
prune:
automatic: true
number: 3
Deployment Commands
# Install Serverless Framework
npm install -g serverless
# Install project dependencies
npm install
# Deploy to AWS
serverless deploy --stage dev
# Deploy a single function
serverless deploy function -f getItems
# View logs
serverless logs -f getItems --tail
# Run locally for development
serverless offline
# Remove all resources
serverless remove --stage dev
AWS SAM (Serverless Application Model)
AWS SAM is AWS’s native infrastructure-as-code tool for serverless applications.
# template.yaml - AWS SAM Template
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: Serverless API with SAM
Globals:
Function:
Runtime: python3.11
Timeout: 10
MemorySize: 256
Environment:
Variables:
TABLE_NAME: !Ref ItemsTable
LOG_LEVEL: INFO
Parameters:
Stage:
Type: String
Default: dev
AllowedValues:
- dev
- staging
- prod
Resources:
# API Gateway
ApiGateway:
Type: AWS::Serverless::HttpApi
Properties:
StageName: !Ref Stage
CorsConfiguration:
AllowOrigins:
- '*'
AllowHeaders:
- Content-Type
- Authorization
AllowMethods:
- GET
- POST
- PUT
- DELETE
# Lambda Functions
GetItemsFunction:
Type: AWS::Serverless::Function
Properties:
FunctionName: !Sub '${AWS::StackName}-get-items'
Handler: app.get_items
CodeUri: src/
Policies:
- DynamoDBReadPolicy:
TableName: !Ref ItemsTable
Events:
GetAll:
Type: HttpApi
Properties:
ApiId: !Ref ApiGateway
Path: /items
Method: GET
GetOne:
Type: HttpApi
Properties:
ApiId: !Ref ApiGateway
Path: /items/{id}
Method: GET
CreateItemFunction:
Type: AWS::Serverless::Function
Properties:
FunctionName: !Sub '${AWS::StackName}-create-item'
Handler: app.create_item
CodeUri: src/
Policies:
- DynamoDBCrudPolicy:
TableName: !Ref ItemsTable
Events:
Create:
Type: HttpApi
Properties:
ApiId: !Ref ApiGateway
Path: /items
Method: POST
# DynamoDB Table
ItemsTable:
Type: AWS::DynamoDB::Table
Properties:
TableName: !Sub '${AWS::StackName}-items'
BillingMode: PAY_PER_REQUEST
AttributeDefinitions:
- AttributeName: id
AttributeType: S
KeySchema:
- AttributeName: id
KeyType: HASH
Outputs:
ApiEndpoint:
Description: API Gateway endpoint URL
Value: !Sub 'https://${ApiGateway}.execute-api.${AWS::Region}.amazonaws.com/${Stage}'
TableName:
Description: DynamoDB table name
Value: !Ref ItemsTable
# SAM CLI commands
# Build the application
sam build
# Run locally
sam local start-api
# Invoke single function locally
sam local invoke GetItemsFunction --event events/get.json
# Deploy with guided prompts
sam deploy --guided
# Deploy to specific environment
sam deploy --stack-name my-api-prod --parameter-overrides Stage=prod
# View logs
sam logs -n GetItemsFunction --tail
Handling Cold Starts
Cold starts occur when Lambda creates a new execution environment. Optimize them with these techniques:
# Optimized Python Lambda with connection reuse
import json
import os
import boto3
from functools import lru_cache
# Initialize clients OUTSIDE the handler for reuse across invocations
dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table(os.environ['TABLE_NAME'])
@lru_cache(maxsize=1)
def get_config():
"""Cache configuration to avoid repeated calls."""
# Expensive initialization done once
return {
'feature_flags': fetch_feature_flags(),
'secrets': fetch_secrets(),
}
def handler(event, context):
# Handler code uses pre-initialized resources
config = get_config()
item_id = event['pathParameters']['id']
response = table.get_item(Key={'id': item_id})
return {
'statusCode': 200,
'body': json.dumps(response.get('Item', {}))
}
# Provisioned Concurrency for latency-sensitive functions
functions:
criticalApi:
handler: handlers/critical.handler
provisionedConcurrency: 5 # Keep 5 instances warm
events:
- httpApi:
path: /critical
method: get
API Authentication
# JWT Authorizer with Cognito
provider:
httpApi:
authorizers:
cognitoAuthorizer:
type: jwt
identitySource: $request.header.Authorization
issuerUrl: !Sub 'https://cognito-idp.${self:provider.region}.amazonaws.com/${CognitoUserPool}'
audience:
- !Ref CognitoUserPoolClient
functions:
protectedEndpoint:
handler: handlers/protected.handler
events:
- httpApi:
path: /protected
method: get
authorizer:
name: cognitoAuthorizer
# Custom Lambda Authorizer
import json
import jwt
import os
def authorizer(event, context):
token = event.get('authorizationToken', '').replace('Bearer ', '')
try:
# Verify JWT token
decoded = jwt.decode(
token,
os.environ['JWT_SECRET'],
algorithms=['HS256']
)
return generate_policy(
decoded['sub'],
'Allow',
event['methodArn'],
context={'userId': decoded['sub'], 'email': decoded.get('email')}
)
except jwt.ExpiredSignatureError:
raise Exception('Unauthorized') # Returns 401
except jwt.InvalidTokenError:
raise Exception('Unauthorized')
def generate_policy(principal_id, effect, resource, context=None):
return {
'principalId': principal_id,
'policyDocument': {
'Version': '2012-10-17',
'Statement': [{
'Action': 'execute-api:Invoke',
'Effect': effect,
'Resource': resource
}]
},
'context': context or {}
}
Common Mistakes to Avoid
1. Creating Connections Inside Handler
# WRONG - New connection on every invocation
def handler(event, context):
dynamodb = boto3.resource('dynamodb') # Slow!
table = dynamodb.Table('my-table')
return table.get_item(Key={'id': '123'})
# CORRECT - Reuse connections across invocations
dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table(os.environ['TABLE_NAME'])
def handler(event, context):
return table.get_item(Key={'id': '123'})
2. Not Setting Proper Timeouts
# WRONG - Default 6 second timeout may be too short
functions:
slowOperation:
handler: handlers/slow.handler
# No timeout specified - defaults to 6s
# CORRECT - Set appropriate timeout based on workload
functions:
slowOperation:
handler: handlers/slow.handler
timeout: 30 # 30 seconds for data processing
memorySize: 1024 # More memory = more CPU
3. Over-Provisioning Memory
# WRONG - Using max memory for simple functions
functions:
simpleApi:
handler: handlers/simple.handler
memorySize: 3008 # Expensive and unnecessary
# CORRECT - Right-size based on actual usage
# Use AWS Lambda Power Tuning to find optimal configuration
functions:
simpleApi:
handler: handlers/simple.handler
memorySize: 256 # Monitor and adjust based on CloudWatch metrics
Monitoring and Observability
# Structured logging for CloudWatch Logs Insights
import json
import logging
import os
class JsonFormatter(logging.Formatter):
def format(self, record):
log_entry = {
'timestamp': self.formatTime(record),
'level': record.levelname,
'message': record.getMessage(),
'function': os.environ.get('AWS_LAMBDA_FUNCTION_NAME'),
'request_id': getattr(record, 'request_id', None),
}
if record.exc_info:
log_entry['exception'] = self.formatException(record.exc_info)
return json.dumps(log_entry)
logger = logging.getLogger()
handler = logging.StreamHandler()
handler.setFormatter(JsonFormatter())
logger.addHandler(handler)
logger.setLevel(logging.INFO)
def lambda_handler(event, context):
# Add request ID to all log entries
extra = {'request_id': context.aws_request_id}
logger.info('Processing request', extra=extra)
# Your logic here
logger.info('Request completed', extra=extra)
Best Practices Summary
- Initialize outside handler: Reuse database connections and SDK clients.
- Right-size memory: Use Lambda Power Tuning to find optimal configuration.
- Set appropriate timeouts: Match timeout to expected execution time plus buffer.
- Use environment variables: Keep configuration separate from code.
- Implement structured logging: Enable CloudWatch Logs Insights queries.
- Handle errors gracefully: Return proper HTTP status codes and error messages.
- Use Provisioned Concurrency: For latency-sensitive production workloads.
- Enable X-Ray tracing: Understand end-to-end request flow.
Final Thoughts
AWS Lambda and API Gateway make building serverless applications straightforward and cost-effective. You can deploy production-ready APIs without maintaining servers—reducing operational burden while gaining automatic scaling and high availability. The Serverless Framework and AWS SAM simplify deployment and infrastructure management, enabling rapid iteration.
To continue your cloud journey, check out Kubernetes 101: Deploying and Managing Containerised Apps and Secrets Management: Comparing Vault, AWS KMS and Other Tools. For official resources, see the AWS Lambda documentation and Serverless Framework documentation.
1 Comment