Design serverless systems that scale and don't surprise you with costs. Covers event-driven patterns, cold starts, state management, orchestration, and when serverless is the wrong choice.
Serverless architecture enables developers to build and run applications without managing servers, scaling automatically in response to demand. It matters when teams need to rapidly iterate on event-driven workflows, integrate services with minimal latency, or deploy functions that serve variable workloads efficiently. This page details how to design, implement, and debug serverless systems, focusing on patterns that solve recurring problems and anti-patterns that silently degrade performance and reliability.
Functions in serverless systems typically respond to events from sources like queues, object storage, or message brokers. When a single event triggers multiple functions in sequence or parallel, the system becomes more complex, and orchestration becomes critical.
Use a message queue (e.g., Amazon SQS, Azure Service Bus, or Apache Kafka) to decouple event producers from function consumers. A producer publishes an event to a queue, and functions consume messages from it asynchronously.
// Example SQS message
{
"id": "order-12345",
"eventType": "OrderCreated",
"timestamp": "2023-10-05T14:23:11Z",
"data": {
"customerId": "cust-6789",
"items": [
{ "sku": "shoe-001", "quantity": 2 },
{ "sku": "sock-002", "quantity": 4 }
]
}
}
Functions consume messages using a polling loop or a long-polling mechanism. Each function must process the message, update state, and possibly enqueue new messages.
If a function fails after processing a message but before acknowledging it to the queue, the message is redelivered. This leads to duplicate processing. The solution: use idempotent functions and acknowledge after processing.
import json
import boto3
from botocore.exceptions import ClientError
def lambda_handler(event, context):
sqs = boto3.client("sqs")
queue_url = "https://sqs.us-east-1.amazonaws.com/123456789012/orders-queue"
for record in event["Records"]:
message = json.loads(record["body"])
message_id = record["messageId"]
try:
# Process the event
handle_order(message)
# Acknowledge the message after successful processing
sqs.delete_message(
QueueUrl=queue_url,
ReceiptHandle=record["receiptHandle"]
)
except Exception as e:
# Log error and do not acknowledge
print(f"Failed to process {message_id}: {e}")
# Message is redelivered
Polling too frequently (e.g., every 100ms) on a low-throughput queue leads to unnecessary cost and latency. The function wakes up, checks for messages, and sleeps again, even when messages are sparse.
Solution: use long polling with a WaitTimeSeconds of 20–30 seconds. This reduces idle function invocations and improves latency.
When one event triggers multiple functions, fan-out patterns are essential. Use a central event bus (e.g., AWS EventBridge, Google Cloud Pub/Sub, or Azure Event Grid) to route events to multiple subscribers.
// Event posted to EventBridge
{
"source": "com.company.orders",
"detail-type": "OrderCompleted",
"detail": {
"orderId": "ord-123",
"total": 129.99,
"status": "shipped"
}
}
Each function registers a rule to filter events:
// EventBridge rule
{
"EventPattern": {
"source": ["com.company.orders"],
"detail-type": ["OrderCompleted"]
},
"Targets": [
{
"Id": "send-notification",
"Arn": "arn:aws:lambda:us-east-1:123456789012:function:send-email"
},
{
"Id": "update-analytics",
"Arn": "arn:aws:lambda:us-east-1:123456789012:function:update-dashboard"
}
]
}
A common mistake is defining overly specific event patterns that cause functions to miss events. For example, a rule with "detail-type": ["OrderCompleted"] will not match "order-completed" (lowercase) or "OrderCompletedEvent".
Solution: use detail-type with wildcards or source with pattern matching.
"EventPattern": {
"source": ["com.company.orders"],
"detail-type": ["OrderCreated", "OrderCompleted", "OrderCancelled"]
}
Also, use detail fields to match nested data.
Serverless functions are stateless by default. To maintain state across invocations, use external storage: databases, file systems, or object stores.
Functions access a relational or document database to store and retrieve state.
-- PostgreSQL table
CREATE TABLE order_status (
order_id VARCHAR(255) PRIMARY KEY,
status VARCHAR(50) NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
metadata JSONB
);
A function updates order state:
import psycopg2
from psycopg2.extras import RealDictCursor
def update_order_status(order_id, new_status):
conn = psycopg2.connect(
host="orders-db.example.com",
database="orders",
user="app_user",
password="securepass"
)
cur = conn.cursor(cursor_factory=RealDictCursor)
cur.execute(
"""
UPDATE order_status
SET status = %s, updated_at = NOW()
WHERE order_id = %s
RETURNING *
""",
(new_status, order_id)
)
result = cur.fetchone()
conn.commit()
cur.close()
conn.close()
return result
Opening a new database connection per function invocation leads to high latency and connection pool pressure. The function takes 300ms to start, 100ms to open a connection, and 50ms for a query.
Solution: reuse connections via connection pooling with a library like pgBouncer or HikariCP, and cache frequently accessed data in memory.
Also, use async database drivers (e.g., asyncpg in Python) to overlap I/O.
For high-throughput, append-only state, use object storage (e.g., Amazon S3, Azure Blob Storage, Google Cloud Storage) to store event streams.
// S3 object: events/order-12345.json
[
{
"eventId": "e1",
"type": "OrderCreated",
"timestamp": "2023-10-05T14:23:11Z",
"data": { "customerId": "cust-6789" }
},
{
"eventId": "e2",
"type": "PaymentProcessed",
"timestamp": "2023-10-05T14:24:05Z",
"data": { "amount": 129.99, "method": "credit-card" }
}
]
A function reads all events for an order and reconstructs its state.
import boto3
def reconstruct_order_state(order_id):
s3 = boto3.client("s3")
bucket = "event-streams"
key = f"events/{order_id}.json"
response = s3.get_object(Bucket=bucket, Key=key)
events = json.loads(response["Body"].read().decode("utf-8"))
state = {"id": order_id, "status": "created", "items": []}
for event in events:
apply_event(state, event)
return state
def apply_event(state, event):
if event["type"] == "OrderCreated":
state["customerId"] = event["data"]["customerId"]
elif event["type"] == "PaymentProcessed":
state["status"] = "paid"
state["paymentAmount"] = event["data"]["amount"]
When multiple functions write to the same event stream, they can overwrite each other. A function reads the stream, processes events, and writes the updated stream, but another function has already written a new version.
Solution: use optimistic concurrency control with ETag or version fields.
// Write with version check
{
"events": [ ... ],
"version": 3,
"etag": "abc123def456"
}
Before writing, the function checks the current version and ETag:
response = s3.get_object(Bucket=bucket, Key=key)
current_version = response["Metadata"]["version"]
current_etag = response["ETag"]
s3.put_object(
Bucket=bucket,
Key=key,
Body=json.dumps(events),
Metadata={"version": str(version + 1), "etag": new_etag},
IfMatch=current_etag,
IfNoneMatch=current_etag
)
If another function has updated the stream, the IfMatch condition fails, and the write is retried.
Serverless systems span multiple services, each with its own logs, metrics, and traces. Without proper error handling and observability, failures become invisible.
When a function fails to process a message, the message is moved to a dead letter queue (DLQ) for later inspection.
// DLQ message after 3 retries
{
"originalMessage": {
"id": "order-12345",
"eventType": "OrderCreated",
"timestamp": "2023-10-05T14:23:11Z"
},
"error": {
"type": "DatabaseConnectionError",
"message": "Could not connect to PostgreSQL at orders-db.example.com",
"stackTrace": [
"at update_order_status (/var/task/lambda.py:42)",
"at lambda_handler (/var/task/lambda.py:18)"
]
},
"retries": 3,
"lastAttemptedAt": "2023-10-05T14:26:12Z"
}
Functions run with default retry settings but no DLQ. A message fails, is retried, and then disappears without logging. The team never sees the root cause.
Solution: configure dead letter queues explicitly.
// AWS Lambda function configuration
{
"FunctionName": "process-order",
"DeadLetterConfig": {
"OnSuccess": {
"TopicArn": "arn:aws:sns:us-east-1:123456789012:order-processed"
},
"OnFailure": {
"TargetArn": "arn:aws:sqs:us-east-1:123456789012:order-dlq"
}
},
"MaximumRetryAttempts": 3
}
Functions can also publish to a success topic for monitoring.
Use a distributed tracing system to correlate function invocations across services.
// Trace context in HTTP headers
{
"traceparent": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f06732f8503307-01",
"tracestate": "concurrent=10,client=23"
}
Each function reads the traceparent header, creates a child span, and propagates it downstream.
from opentelemetry import trace
from opentelemetry.propagate import extract
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
trace.set_tracer_provider(TracerProvider())
tracer = trace.get_tracer(__name__)
exporter = OTLPSpanExporter(endpoint="http://tracing-collector:4317")
processor = BatchSpanProcessor(exporter)
trace.get_tracer_provider().add_span_processor(processor)
def lambda_handler(event, context):
# Extract trace context from incoming request
carrier = {}
for k, v in context.get("invokedFunctionArn").items():
carrier[k] = v
span = tracer.start_span("process_order", context=extract(carrier))
try:
# Process event
with span:
handle_order(event)
except Exception as e:
span.set_attribute("error", str(e))
span.record_exception(e)
raise
finally:
span.end()
Functions create spans with inconsistent names. One function names the span "OrderProcessing", another "ProcessOrder", making it hard to analyze end-to-end traces.
Solution: use standardized span names based on event types.
def create_span(event_type, parent_context=None):
span_name = f"event.{event_type}"
return tracer.start_span(span_name, context=parent_context)
Also, include metadata in spans: event ID, message size, processing time.
This page was rewritten on 17 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.
We use cookies for analytics (Google Analytics) and advertising (Google AdSense) to improve your experience and support free content. Privacy Policy