StackPractices
advanced By Mathias Paulenko

Complete Guide to Serverless Architecture

Decide when to go serverless and when not to. Covers FaaS patterns, event-driven design, cold starts, cost models, vendor lock-in, and migration strategies for production serverless applications.

Introduction

Infinite scaling, zero maintenance, pay only for what you use — that’s how serverless gets sold. It shines on some workloads and falls apart on others. Pick it for the wrong reasons and you get surprise bills, cold start latency, and debugging sessions nobody enjoys. Spend a few minutes here and you’ll walk away knowing whether serverless fits your workload, plus what it takes to keep it running once it hits production.

What Serverless Actually Means

Serverless does not mean “no servers.” It means you do not manage them. Provisioning, scaling, patching, availability — that’s the provider’s job now. Your job shrinks to writing functions and wiring triggers.

Traditional:    You manage VMs → You deploy app → You scale → You patch
Container:      You build images → Orchestrator deploys → You configure scaling
Serverless:     You write functions → Provider handles everything else

Core Characteristics

  • No server management: the provider patches the OS and provisions capacity
  • Auto-scaling: goes from 0 to thousands of concurrent executions on its own
  • Pay-per-use: billed by execution time and memory, never for idle time
  • Event-driven: functions fire on HTTP requests, queue messages, timers, or cloud events
  • Stateless: every invocation stands alone — nothing survives in memory between calls

When to Go Serverless

Good Fit: Event-Driven Workloads

# AWS Lambda triggered by S3 upload
import json
import boto3

def lambda_handler(event, context):
    for record in event["Records"]:
        bucket = record["s3"]["bucket"]["name"]
        key = record["s3"]["object"]["key"]
        
        # Process uploaded file
        process_file(bucket, key)
    
    return {"statusCode": 200, "body": json.dumps({"processed": len(event["Records"])})}

Serverless shines for workloads that are:

  • Intermittent: Traffic spikes and drops unpredictably
  • Event-driven: Triggered by uploads, messages, or webhooks
  • Short-lived: Each execution completes in seconds, not hours
  • Stateless: No need for long-running connections or in-memory state

Good Fit: HTTP APIs with Variable Traffic

# API Gateway + Lambda for REST endpoints
def lambda_handler(event, context):
    http_method = event["httpMethod"]
    path = event["path"]
    
    if path == "/users" and http_method == "GET":
        users = db.users.list(limit=100)
        return {"statusCode": 200, "body": json.dumps(users)}
    
    if path == "/users" and http_method == "POST":
        body = json.loads(event["body"])
        user = db.users.create(body)
        return {"statusCode": 201, "body": json.dumps(user)}
    
    return {"statusCode": 404, "body": json.dumps({"error": "Not found"})}

Good Fit: Scheduled Tasks

# CloudWatch Events + Lambda for cron jobs
def lambda_handler(event, context):
    # Run nightly cleanup
    delete_expired_sessions()
    send_daily_reports()
    update_aggregates()
    
    return {"status": "complete"}

Good Fit: Stream Processing

# Lambda processing DynamoDB streams
def lambda_handler(event, context):
    for record in event["Records"]:
        if record["eventName"] == "INSERT":
            new_item = record["dynamodb"]["NewImage"]
            # Send welcome email for new users
            if new_item.get("type", {}).get("S") == "user":
                send_welcome_email(new_item["email"]["S"])
    
    return {"status": "processed"}

When NOT to Go Serverless

Bad Fit: Long-Running Tasks

AWS Lambda has a 15-minute timeout. Anything longer — video encoding, heavy data crunching — belongs on containers or VMs.

Bad Fit: Real-Time Applications

WebSockets, streaming, and long-polling need connections that stay open. Functions are stateless by design and live for seconds at a time. Dedicated servers or a managed WebSocket service fit better.

Bad Fit: High-Performance Computing

Lambda tops out at 10GB of RAM, and the CPU you get scales with the memory you buy. ML training, simulations, anything CPU-hungry — reach for GPU instances or an HPC cluster instead.

Bad Fit: Predictable, Constant Traffic

With steady 24/7 traffic, serverless costs more than reserved instances. Containers with autoscaling deliver the same elasticity for less.

Bad Fit: Complex Stateful Workflows

Serverless functions are stateless. Shopping carts or multi-step forms need state somewhere — and “somewhere” means a database or Redis, plus the latency and complexity that come with it.

Serverless Design Patterns

Trigger Patterns

Most serverless functions fall into one of five trigger shapes. Picking the right one determines how the function scales and how failures surface.

PatternUse CaseExample
Function per HTTP endpointREST APIsAPI Gateway → Lambda
Event-driven functionAsync processingS3 upload → thumbnail generator
Scheduled functionCron jobsCloudWatch Events → nightly report
Queue-triggered functionDecoupled workloadsSQS → order processor
Stream-triggered functionReal-time dataDynamoDB Streams → cache updater

Function Composition

Break complex operations into smaller functions that chain together.

# Step Functions state machine for order processing
{
  "StartAt": "ValidateOrder",
  "States": {
    "ValidateOrder": {
      "Type": "Task",
      "Resource": "arn:aws:lambda:us-east-1:123:function:validate-order",
      "Next": "ChargePayment"
    },
    "ChargePayment": {
      "Type": "Task",
      "Resource": "arn:aws:lambda:us-east-1:123:function:charge-payment",
      "Next": "CheckResult"
    },
    "CheckResult": {
      "Type": "Choice",
      "Choices": [
        {"Variable": "$.paymentStatus", "StringEquals": "success", "Next": "ShipOrder"},
        {"Variable": "$.paymentStatus", "StringEquals": "failed", "Next": "NotifyFailure"}
      ]
    },
    "ShipOrder": {
      "Type": "Task",
      "Resource": "arn:aws:lambda:us-east-1:123:function:ship-order",
      "End": true
    },
    "NotifyFailure": {
      "Type": "Task",
      "Resource": "arn:aws:lambda:us-east-1:123:function:notify-failure",
      "End": true
    }
  }
}

Fan-Out / Fan-In

One function triggers multiple parallel functions, then aggregates results.

import boto3
import json
from concurrent.futures import ThreadPoolExecutor

lambda_client = boto3.client("lambda")

def fan_out_handler(event, context):
    items = event["items"]
    
    # Invoke processor for each item in parallel
    with ThreadPoolExecutor(max_workers=10) as executor:
        futures = []
        for item in items:
            futures.append(
                executor.submit(
                    lambda_client.invoke,
                    FunctionName="process-item",
                    Payload=json.dumps({"item": item})
                )
            )
        
        results = [json.loads(f.result()["Payload"].read()) for f in futures]
    
    # Aggregate results
    return {"processed": len(results), "results": results}

Event Sourcing

Treat the stored events as the source of truth, and let functions project them into read models.

# Function 1: Save event
def save_event_handler(event, context):
    event_store.append({
        "aggregate_id": event["aggregateId"],
        "event_type": event["type"],
        "data": event["data"],
        "timestamp": event["timestamp"]
    })
    return {"status": "saved"}

# Function 2: Project to read model (triggered by event store)
def project_handler(event, context):
    for record in event["Records"]:
        event_data = json.loads(record["body"])
        
        if event_data["event_type"] == "UserCreated":
            db.users.upsert({
                "id": event_data["aggregate_id"],
                "name": event_data["data"]["name"],
                "email": event_data["data"]["email"]
            })

Sagas and Compensating Transactions

Functions cannot hold distributed ACID transactions across services. When a business operation spans several functions, use the saga pattern: each function performs its step and publishes an event, and if a step fails, a compensating function undoes the earlier work. Step Functions expresses compensation with Catch blocks that route failures to a rollback state.

# Order processing saga — payment failure triggers compensation
Comment: Order processing saga
StartAt: ValidateOrder
States:
  ValidateOrder:
    Type: Task
    Resource: arn:aws:lambda:us-east-1:123:function:validate-order
    Next: ProcessPayment
  ProcessPayment:
    Type: Task
    Resource: arn:aws:lambda:us-east-1:123:function:process-payment
    Catch:
      - ErrorEquals: ["PaymentFailed"]
        ResultPath: "$.error"
        Next: RefundOrder
    Next: NotifySuccess
  RefundOrder:
    Type: Task
    Resource: arn:aws:lambda:us-east-1:123:function:refund-order
    End: true
  NotifySuccess:
    Type: Task
    Resource: arn:aws:lambda:us-east-1:123:function:notify-success
    End: true

For single-service consistency, the outbox pattern is simpler: the function writes to the database and records the event in the same transaction, then a separate process reads the outbox table and publishes events.

Cold Starts

A cold start happens when nobody has called the function recently: the provider has to spin up a fresh container and load the runtime before your code gets to run — costing 1–10 seconds up front.

Cold Start Factors

FactorImpactMitigation
RuntimeJava > .NET > Python > Node.js > GoChoose fast-start runtimes (Go, Node.js)
Package sizeLarger = slower cold startMinimize dependencies
VPC configurationVPC adds 1-2s cold startUse VPC-less if possible
Memory allocationMore memory = faster startupAllocate 512MB+ for CPU-heavy functions
Provisioned concurrencyEliminates cold startPay for always-warm instances

Mitigating Cold Starts

# Lazy initialization: load heavy dependencies outside handler
import boto3
import json

# These load on cold start only
s3 = boto3.client("s3")
dynamodb = boto3.resource("dynamodb")
table = dynamodb.Table("users")

def lambda_handler(event, context):
    # Handler is fast — dependencies already loaded
    user_id = event["pathParameters"]["userId"]
    response = table.get_item(Key={"id": user_id})
    
    return {
        "statusCode": 200,
        "body": json.dumps(response.get("Item", {}))
    }

Provisioned Concurrency

Latency-sensitive endpoints can pay for provisioned concurrency, which keeps instances warm.

# AWS CLI: allocate provisioned concurrency
aws lambda put-provisioned-concurrency-config \
  --function-name my-api \
  --qualifier live \
  --provisioned-concurrent-executions 10

Other options worth knowing: scheduled keep-alive pings eliminate cold starts for rarely-invoked functions at near-zero cost, and compiling to native code (Go, Rust, or .NET AOT) cuts initialization time compared to interpreted runtimes. For a hands-on walkthrough, see Optimize AWS Lambda Cold Starts.

Cost Model

How Serverless Pricing Works

Cost = (Number of requests) × (Execution duration in ms) × (Memory allocated) × (Price per GB-ms)

AWS Lambda pricing (example):

  • $0.20 per 1 million requests
  • $0.0000166667 per GB-second
  • Free tier: 1 million requests + 400,000 GB-seconds per month

Cost Comparison Example

# Scenario: 10 million requests/month, 100ms each, 512MB memory

# Serverless (Lambda)
requests_cost = 10_000_000 / 1_000_000 * 0.20  # $2.00
compute_cost = 10_000_000 * 0.1 * 0.5 / 1024 * 0.0000166667 * 1000  # ~$8.13
total_serverless = requests_cost + compute_cost  # ~$10.13/month

# Container (ECS Fargate: 0.5 vCPU, 1GB RAM, always running)
container_cost = 730 * (0.025 * 0.5 + 0.003 * 1)  # ~$10.22/month

# Break-even: ~10M requests/month at 100ms each
# Below 10M: serverless is cheaper
# Above 10M: container becomes cheaper

Cost Optimization Tips

  • Right-size memory: More memory can reduce execution time, lowering total cost
  • Reduce execution time: Optimize code to finish faster
  • Use caching: Cache expensive computations in Redis or DynamoDB DAX
  • Avoid over-provisioning: Start with 128MB and increase only if needed
  • Monitor costs: Set billing alerts to catch unexpected spikes

Vendor Lock-In

Serverless platforms differ more than they look. Moving from Lambda to Cloud Functions or Azure Functions means rewriting handlers, triggers, and infrastructure config.

Lock-In Factors

FactorAWS LambdaGoogle Cloud FunctionsAzure Functions
Handler signaturelambda_handler(event, context)handler(request)main(req: func.HttpRequest)
TriggersAPI Gateway, S3, SQS, DynamoDBHTTP, Pub/Sub, Cloud StorageHTTP, Blob, Queue, Event Grid
Infrastructure as CodeCloudFormation, SAM, CDKCloud Deployment ManagerARM, Bicep
State managementStep FunctionsWorkflowsDurable Functions

Reducing Lock-In

# Abstract the handler from business logic
def process_order(order_data: dict) -> dict:
    # Pure business logic — no cloud-specific code
    validate(order_data)
    charge_payment(order_data)
    ship_order(order_data)
    return {"status": "complete"}

# AWS Lambda handler
def lambda_handler(event, context):
    order_data = json.loads(event["body"])
    result = process_order(order_data)
    return {"statusCode": 200, "body": json.dumps(result)}

# Google Cloud Functions handler
def handler(request):
    order_data = request.get_json()
    result = process_order(order_data)
    return json.dumps(result)

Migration Strategy

Strangler Fig Pattern

Gradually replace a monolith with serverless functions. Route traffic to serverless for new features first, then progressively replace existing functionality — see the strangler fig pattern for the full migration mechanics.

# API Gateway: route new endpoints to Lambda, old to monolith
{
  "routes": [
    {"path": "/api/v2/users", "target": "lambda:users-v2"},
    {"path": "/api/v1/*", "target": "http://monolith.example.com"}
  ]
}

Migration Steps

  1. Identify candidates: pick endpoints that are intermittent, event-driven, or stateless
  2. Start small: move one endpoint to serverless and watch it
  3. Test thoroughly: check performance, cost, and reliability before going further
  4. Route traffic: let API Gateway split requests between old and new
  5. Monitor: compare metrics on both sides
  6. Expand: bring more endpoints over as confidence grows
  7. Decommission: shut down the old infrastructure once the migration lands

Worked Example: Image Processing Pipeline

Theory only goes so far. Here is a complete event-driven pipeline — upload an image to S3 and get three resized versions plus a metadata record — with the infrastructure, monitoring, and cost numbers behind it.

flowchart diagram: User upload

Step 1 — Upload. Someone drops an image into uploads/originals/ and the S3:ObjectCreated event fires the resize function.

Step 2 — Resize. The function reads the image, cuts thumbnail (100×100), medium (500×500) and large (1200×1200) versions into uploads/resized/, then drops a message on image-processed-queue carrying the generated keys. It needs 1024 MB of memory and a 30-second timeout — large images take 10–15 seconds to process.

Step 3 — Metadata. A second Lambda consumes the queue, extracts dimensions, format, size, and hash, and writes them to an ImageMetadata table (PK imageId, SK version). The message is deleted on success; after three failed receives it lands in image-processed-dlq.

The infrastructure in Terraform:

resource "aws_lambda_function" "resize" {
  function_name = "image-resize"
  handler       = "handler.lambda_handler"
  runtime       = "python3.11"
  memory_size   = 1024
  timeout       = 30

  environment {
    variables = {
      OUTPUT_BUCKET = "uploads-resized"
      QUEUE_URL     = aws_sqs_queue.image_processed.id
    }
  }
}

resource "aws_sqs_queue" "image_processed" {
  name               = "image-processed-queue"
  visibility_timeout = 60 # must exceed the Lambda timeout

  redrive_policy = jsonencode({
    deadLetterTargetArn = aws_sqs_queue.dlq.arn
    maxReceiveCount     = 3
  })
}

Note the visibility_timeout of 60 seconds — it must exceed the Lambda timeout, or a message can reappear in the queue while a function is still processing it and get handled twice.

Monitoring. CloudWatch alarms on error rate above 1% and p95 duration above 20 seconds, an SNS notification to Slack when anything lands in the DLQ, and X-Ray tracing to break latency down per step.

Partial batch failures. When the metadata Lambda polls a batch of SQS messages and one message poisons the batch, returning a generic error makes SQS retry every message — including the ones that processed fine. Return batchItemFailures with only the failed message IDs so the rest get acknowledged and deleted.

Cost at 10k images per day: it lands around $19/month — Lambda eats $15 of that (1.5M invocations), S3 storage $2, SQS $0.40, and on-demand DynamoDB $1.25.

Observability

Serverless is where debugging goes to hurt: no SSH into a function, no debugger to attach.

Structured Logging

import json
import logging

logger = logging.getLogger()
logger.setLevel(logging.INFO)

def lambda_handler(event, context):
    request_id = context.aws_request_id
    
    logger.info(json.dumps({
        "request_id": request_id,
        "event": "function_start",
        "user_id": event.get("user_id"),
        "action": event.get("action")
    }))
    
    try:
        result = process_request(event)
        logger.info(json.dumps({
            "request_id": request_id,
            "event": "function_success",
            "duration_ms": 150
        }))
        return result
    except Exception as e:
        logger.error(json.dumps({
            "request_id": request_id,
            "event": "function_error",
            "error": str(e)
        }))
        raise

Distributed Tracing

Tracing tools like X-Ray or OpenTelemetry follow a request as it hops across functions, and the AWS SDK calls get captured automatically along the way.

from aws_xray_sdk.core import patch_all
import boto3

patch_all()

def lambda_handler(event, context):
    # X-Ray automatically traces this function
    # and any AWS SDK calls it makes
    dynamodb = boto3.resource("dynamodb")
    table = dynamodb.Table("users")
    response = table.get_item(Key={"id": event["user_id"]})
    return response.get("Item")

Security

Least Privilege IAM

Give each function only the permissions it actually needs.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["dynamodb:GetItem", "dynamodb:PutItem"],
      "Resource": "arn:aws:dynamodb:us-east-1:123:table/users"
    }
  ]
}

Environment Variables for Secrets

Don’t hardcode secrets — pull them from Secrets Manager or Parameter Store through environment variables.

import os
import boto3

def get_secret():
    secret_name = os.environ["DB_SECRET_NAME"]
    client = boto3.client("secretsmanager")
    response = client.get_secret_value(SecretId=secret_name)
    return json.loads(response["SecretString"])

Common Mistakes

  • Monolithic Lambda: packing an entire application into one function. Split it into single-purpose functions so failures and scaling stay isolated.
  • Synchronous waiting: calling slow downstream services synchronously inside a function. You pay for idle wait time — use queues or step functions instead.
  • Ignoring timeout limits: Lambda caps at 15 minutes and Azure Functions at 10. Longer jobs belong on ECS, Batch, or Fargate.
  • Treating functions like servers: writing state to memory or local disk. Anything written there disappears when the container recycles — persist externally.
  • No retry strategy: a failed async invocation loses work quietly. Always attach a dead-letter queue and an alarm.
  • Over-provisioned memory: memory also controls CPU allocation on Lambda, so blindly raising it can help — but measure it, because it also raises the per-millisecond price.

Production Checklist

  • Functions are stateless and idempotent
  • Cold start mitigation (provisioned concurrency or lazy init)
  • Memory allocation right-sized
  • Timeout set appropriately (not default max)
  • IAM roles follow least privilege
  • Secrets in Secrets Manager or Parameter Store
  • Structured logging with request IDs
  • Distributed tracing enabled
  • Dead letter queue for failed async invocations
  • Retry strategy configured
  • Cost monitoring and billing alerts
  • Deployment via Infrastructure as Code (SAM, CDK, Serverless Framework)

See Also

Frequently Asked Questions

Is serverless cheaper than containers?

It depends on your traffic shape. Intermittent or spiky traffic favors serverless because you never pay for idle time; steady high traffic favors containers, where reserved capacity is cheaper. The break-even usually lands around 60–70% sustained utilization.

How do I handle long-running tasks in serverless?

Chop them into smaller pieces with Step Functions or a queue-based pattern so each function stays under the timeout. For genuinely long jobs measured in hours, AWS Batch or Fargate is the better tool.

Can I use serverless for websockets?

Not directly. API Gateway's WebSocket API can hold the connection open while Lambda processes each message; managed options like AppSync subscriptions or Pusher work too.

How do I test serverless functions locally?

For a local Lambda runtime, sam local invoke from the SAM CLI or serverless invoke local both emulate the execution environment. For integration tests, LocalStack emulates the AWS services your function talks to.

What is provisioned concurrency?

Provisioned concurrency pre-warms a fixed number of instances so they answer instantly. You pay for that capacity whether it runs or not — worth it for latency-sensitive endpoints where a cold start is a deal-breaker.

How do I handle database connections in serverless?

Every invocation can open a fresh database connection, which can exhaust the pool fast. Put a proxy in front of it — Amazon RDS Proxy or pgBouncer. Or skip pooling entirely: DynamoDB has no connection pool to exhaust, and Aurora Serverless scales connections on its own.