Serverless Architecture: Patterns & Anti-Patterns

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.

Event-Driven Function Orchestration

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.

Chaining Functions with Message Queues

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.

Common Failure Mode: Message Loss on Function Crash

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

Anti-Pattern: Over-Polling with High Latency

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.

Fan-Out with Event Bridge or Pub/Sub

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"
    }
  ]
}

Anti-Pattern: Narrow Event Patterns

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.

Stateful Computation with Persistent Storage

Serverless functions are stateless by default. To maintain state across invocations, use external storage: databases, file systems, or object stores.

Using Databases for Shared State

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

Anti-Pattern: High Latency on Database Connections

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.

Event Sourcing with Object Storage

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"]

Anti-Pattern: Event Overwriting

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.

Error Handling and Observability

Serverless systems span multiple services, each with its own logs, metrics, and traces. Without proper error handling and observability, failures become invisible.

Dead Letter Queues for Failed Functions

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"
}

Anti-Pattern: No DLQ Configuration

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.

Distributed Tracing with OpenTelemetry

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()

Anti-Pattern: Inconsistent Span Naming

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.

Summary: Key Principles

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.