Serverless Best Practices — Production-Ready Serverless
In this tutorial, you will learn about Serverless Best Practices. We cover key concepts, practical examples, and best practices to help you master this topic.
Serverless best practices cover function design, error handling, performance optimization, cost management, Observability, testing, and operational patterns for production serverless applications.
What You'll Learn
By the end of this lesson you will understand best practices for designing, building, deploying, and operating serverless applications in production with reliability, performance, and cost efficiency.
Why It Matters
Serverless applications that start small can become expensive, slow, or unreliable without proper practices. Following established patterns prevents common production issues and ensures your application scales gracefully.
Real-World Use
DodaZIP's serverless conversion pipeline follows all serverless best practices: single-responsibility functions, connection reuse, structured logging, cost monitoring, and automated testing with local emulation.
flowchart TD
BP[Best Practices] --> D["Single Responsibility"]
BP --> C["Connection Reuse"]
BP --> L["Structured Logging"]
BP --> T["Automated Testing"]
BP --> CO["Cost Monitoring"]
BP --> ER["Error Handling"]
BP --> P["Performance Tuning"]
style BP fill:#f90,color:#fff
Function Design
Each function should do one thing. Keep handlers thin. Initialize connections outside the handler.
# function_design.py
# Best practice function design
import json
# Good: connection initialized once, reused across invocations
db_client = None
def get_db_client():
global db_client
if db_client is None:
db_client = {"connected": True}
print(" Initialized database connection")
return db_client
def lambda_handler(event, context):
db = get_db_client()
user_id = event.get("pathParameters", {}).get("id")
print(f"Fetching user {user_id}")
return {
"statusCode": 200,
"body": json.dumps({"id": user_id, "name": "Alice"})
}
print("Invocation 1:")
print(lambda_handler({"pathParameters": {"id": "1"}}, None)["body"])
print("\nInvocation 2 (reuses connection):")
print(lambda_handler({"pathParameters": {"id": "2"}}, None)["body"])
Expected output:
Invocation 1:
Initialized database connection
Fetching user 1
{"id": "1", "name": "Alice"}
Invocation 2 (reuses connection):
Fetching user 2
{"id": "2", "name": "Alice"}
Error Handling
Handle all error types, implement dead-letter queues, and return meaningful error responses.
# error_handling_best.py
# Error handling best practices
import json
import traceback
class AppError(Exception):
def __init__(self, message, status_code=400):
self.message = message
self.status_code = status_code
def lambda_handler(event, context):
try:
body = json.loads(event.get("body", "{}"))
if not body.get("email"):
raise AppError("Email is required", 400)
result = process_user(body)
return {"statusCode": 200, "body": json.dumps(result)}
except json.JSONDecodeError:
return {"statusCode": 400, "body": json.dumps({"error": "Invalid JSON"})}
except AppError as e:
return {"statusCode": e.status_code, "body": json.dumps({"error": e.message})}
except Exception as e:
print(f"Unhandled error: {traceback.format_exc()}")
return {"statusCode": 500, "body": json.dumps({"error": "Internal server error"})}
def process_user(data):
return {"email": data["email"], "status": "created"}
tests = [
{"body": "not json"},
{"body": '{}'},
{"body": '{"email": "alice@example.com"}'},
]
for t in tests:
r = lambda_handler(t, None)
print(f"Status: {r['statusCode']}, Body: {r['body']}")
Cost Optimization
Monitor function costs, set appropriate memory, use provisioned concurrency wisely, and clean up old versions.
# cost_optimization.py
# Cost optimization strategies
def estimate_monthly_cost(invocations, avg_duration_ms, memory_mb):
gb_s = invocations * (avg_duration_ms / 1000) * (memory_mb / 1024)
compute_cost = max(0, gb_s - 400000) * 0.0000166667
request_cost = max(0, invocations - 1000000) * 0.0000002
return compute_cost + request_cost
def optimize_memory(invocations, duration_ms):
configs = []
for memory in [128, 256, 512, 1024, 2048, 3008]:
cost = estimate_monthly_cost(invocations, duration_ms * (512 / memory), memory)
configs.append((memory, cost))
best = min(configs, key=lambda x: x[1])
print("Cost optimization for 5M invocations/month, 200ms at 512MB:")
for mem, cost in configs:
marker = " <- BEST" if mem == best[0] else ""
print(f" {mem}MB -> ${cost:.2f}/month{marker}")
optimize_memory(5000000, 200)
Common Mistakes
Not testing for failure scenarios: Test what happens when DynamoDB throttles, API Gateway times out, or memory is exhausted.
Ignoring function timeouts: Functions that silently timeout cause unpredictable failures. Always set appropriate timeouts.
Not using dead-letter queues: Failed async invocations are lost. Configure DLQs for all production functions.
Over-monitoring without action: Collecting every metric without alerting is noise. Focus on actionable alarms.
Not planning for regional outages: Serverless is regional. Design for multi-region failover for critical applications.
Practice Questions
What is the single responsibility principle for Lambda functions? Each function should perform exactly one business operation. Avoid functions that handle multiple unrelated tasks.
Why initialize connections outside the handler? The execution context is reused for warm invocations. Initializing once saves time on subsequent calls.
How do you optimize serverless costs? Right-size memory, minimize execution time, use provisioned concurrency only when needed, and clean up old versions.
What should you monitor in production serverless? Error rates, duration percentiles, throttles, invocation count, cold start rate, and cost.
Challenge: Review a serverless application against 10 best practices and create a checklist for production readiness review.
FAQ
Mini Project
Create a production-ready Lambda function that follows all best practices: connection reuse, structured logging, error handling, input validation, and secret management.
import json
import logging
import os
import re
logger = logging.getLogger()
logger.setLevel(logging.INFO)
class UserService:
def __init__(self):
self.db = None
self.secrets = None
def initialize(self):
if self.db is None:
self.secrets = {"db_url": "postgres://..."}
self.db = {"connected": True}
logger.info("Initialized database connection")
user_service = UserService()
def validate_email(email):
return bool(re.match(r"[^@]+@[^@]+\.[^@]+", email))
def lambda_handler(event, context):
user_service.initialize()
try:
body = json.loads(event.get("body", "{}"))
logger.info("Processing user creation", extra={"email": body.get("email")})
if not body.get("email") or not validate_email(body["email"]):
return {"statusCode": 400, "body": json.dumps({"error": "Invalid email"})}
return {"statusCode": 201, "body": json.dumps({"email": body["email"], "status": "created"})}
except Exception as e:
logger.error("Failed to create user", extra={"error": str(e)})
return {"statusCode": 500, "body": json.dumps({"error": "Internal error"})}
print(lambda_handler({"body": json.dumps({"email": "alice@example.com"})}, None)["body"])
What's Next
Next: Serverless Python for Python-specific patterns.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro