Skip to content
SP 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.

Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.

Introduction

Serverless architecture promises infinite scaling, zero maintenance, and pay-per-use pricing. The reality is more nuanced. Serverless excels for certain workloads and struggles with others. Choosing serverless for the wrong reasons leads to unexpected costs, cold start latency, and debugging nightmares. This guide helps you decide when serverless is the right choice and how to architect serverless applications that work in production.

What Serverless Actually Means

Serverless does not mean “no servers.” It means you do not manage servers. The cloud provider handles provisioning, scaling, patching, and availability. You write functions and configure 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: No OS patching, no capacity provisioning
  • Auto-scaling: Scales from 0 to thousands of concurrent executions
  • Pay-per-use: Billed by execution time and memory, not by idle time
  • Event-driven: Functions triggered by HTTP requests, queue messages, timers, or cloud events
  • Stateless: Each invocation is independent; no in-memory state 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. For tasks that run longer (video encoding, large data processing), use containers or VMs.

Bad Fit: Real-Time Applications

WebSockets, streaming, and long-polling require persistent connections. Serverless functions are stateless and short-lived. Use dedicated servers or managed WebSocket services.

Bad Fit: High-Performance Computing

Serverless functions have limited CPU and memory (up to 10GB RAM on Lambda). For CPU-intensive workloads (ML training, simulations), use GPU instances or HPC clusters.

Bad Fit: Predictable, Constant Traffic

If your traffic is constant 24/7, serverless is more expensive than reserved instances. A container service with auto-scaling gives you the same scaling at lower cost.

Bad Fit: Complex Stateful Workflows

Serverless functions are stateless. Complex stateful workflows (shopping carts, multi-step forms) require external state management (database, Redis), adding latency and complexity.

Serverless Design Patterns

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

Store events as the source of truth. Functions react to events and project state 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"]
            })

Cold Starts

Cold starts occur when a function has not been invoked recently. The provider needs to provision a new container, load the runtime, and execute your code. This adds 1-10 seconds of latency.

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

For latency-sensitive functions, use provisioned concurrency to keep instances warm.

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

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 considerably. Moving from AWS Lambda to Google Cloud Functions or Azure Functions requires rewriting handlers, triggers, and infrastructure configuration.

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.

# 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: Find intermittent, event-driven, or stateless endpoints
  2. Start small: Migrate one endpoint to serverless
  3. Test thoroughly: Verify performance, cost, and reliability
  4. Route traffic: Use API Gateway to split traffic between old and new
  5. Monitor: Compare metrics between serverless and traditional
  6. Expand: Migrate more endpoints as confidence grows
  7. Decommission: Remove old infrastructure when migration is complete

Observability

Serverless functions are harder to debug than traditional applications. You cannot SSH into a function or attach a debugger.

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

Use AWS X-Ray or OpenTelemetry to trace requests across multiple functions.

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

Grant functions only the permissions they need.

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

Never hardcode secrets. Use environment variables with AWS Secrets Manager or Parameter Store.

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

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)

FAQ

Is serverless cheaper than containers?

It depends on traffic patterns. For intermittent or unpredictable traffic, serverless is cheaper (no idle costs). For constant high traffic, containers are cheaper (reserved capacity). The break-even point is typically around 60-70% sustained utilization.

How do I handle long-running tasks in serverless?

Break them into smaller chunks using Step Functions or a queue-based pattern. Each function should complete within the timeout limit. For truly long-running tasks (hours), use AWS Batch or Fargate instead of Lambda.

Can I use serverless for websockets?

Not directly. Use AWS API Gateway WebSocket API with Lambda, or a managed service like AWS AppSync subscriptions or Pusher. The WebSocket connection is managed by API Gateway; Lambda handles the messages.

How do I test serverless functions locally?

Use the AWS SAM CLI (sam local invoke) or the Serverless Framework’s serverless invoke local. These emulate the Lambda runtime locally. For integration tests, use LocalStack to emulate AWS services.

What is provisioned concurrency?

Provisioned concurrency keeps a specified number of function instances warm and ready to respond immediately. You pay for the provisioned capacity regardless of invocations. Use it for latency-sensitive endpoints where cold starts are unacceptable.

How do I handle database connections in serverless?

Each function invocation may open a new database connection, exhausting connection pools. Use a connection proxy like Amazon RDS Proxy or pgBouncer. Alternatively, use DynamoDB (no connection pools needed) or Aurora Serverless (auto-scaling connections).

See Also

Guide

Complete Guide to Redis Caching Strategies

Master Redis caching with cache-aside, read-through, write-through, write-behind, and refresh-ahead patterns. Covers eviction policies, TTL tuning, serialization, and production operations.

Pattern

Circuit Breaker Pattern

Prevent cascading failures by stopping requests to failing services. An architectural pattern for resilient distributed systems.

Pattern

Strangler Fig: Gradually Replace Legacy by Intercepting

How to gradually replace a legacy system by intercepting routes and routing traffic to new services. Covers strangler fig, incremental migration, and cutover.

Recipe

Orchestrate Serverless Workflows with AWS Step Functions

Build state machine workflows with AWS Step Functions using sequential, parallel, and map states for orchestrating Lambda functions and long-running processes.

Guide

Complete Guide to Apache Kafka in Production

Run Apache Kafka in production with confidence. Covers partitions, replication, consumer groups, monitoring, performance tuning, and operational best practices for high-throughput streaming pipelines.

Guide

Complete Guide to AWS Lambda in Production

Run AWS Lambda in production with confidence. Covers cold start optimization, layers, deployment patterns, observability with X-Ray, security hardening, connection pooling, and cost tuning for production workloads.

Guide

Complete Guide to Serverless Databases

Choose and operate serverless databases for event-driven applications. Covers DynamoDB, Aurora Serverless, FaunaDB, and PlanetScale with pricing, scaling, query patterns, and migration strategies.