Skip to content
Back to Blog
Performance11 min read

Serverless Architecture Patterns: 7 Designs That Scale

Seven battle-tested serverless patterns—from event-driven pipelines to hybrid container designs—that handle real production load without the ops overhead.

Written by Abdul AbrorTechnical Hosting Support Engineer
Serverless Architecture Patterns: 7 Designs That Scale
On this page

Serverless changes how we build. No servers to patch, no capacity planning, pay only for execution time. But the patterns matter more than the platform.

I've seen teams rewrite monoliths into functions without thinking through the workflow, then fight cold starts and distributed tracing nightmares. The right pattern for your workload makes the difference between a system that scales effortlessly and one that costs more than the VMs you replaced.

Here are seven serverless patterns I've used in production, from simple scheduled jobs to complex choreography.

Pattern 1: Event-Driven Processing

The bread and butter of serverless. A file lands in S3, triggers a Lambda. A message hits SQS, starts processing. DynamoDB stream fires, updates a cache.

This pattern decouples producers from consumers completely. The event source doesn't know or care what happens downstream. Scale each piece independently, retry failures without touching the publisher.

I deployed an image resizing pipeline this way. Upload hits S3, fires a function that generates three thumbnail sizes, writes them back, and updates a database record. The upload API doesn't wait for processing—it returns immediately.

import boto3
import os
from PIL import Image

s3 = boto3.client('s3')

def lambda_handler(event, context):
    bucket = event['Records'][0]['s3']['bucket']['name']
    key = event['Records'][0]['s3']['object']['key']

    download_path = f'/tmp/{key}'
    s3.download_file(bucket, key, download_path)

    img = Image.open(download_path)

    for size in [(150, 150), (400, 400), (800, 800)]:
        thumb = img.copy()
        thumb.thumbnail(size)
        thumb_key = f"thumbnails/{size[0]}x{size[1]}/{key}"
        thumb_path = f'/tmp/thumb_{size[0]}.jpg'
        thumb.save(thumb_path)
        s3.upload_file(thumb_path, bucket, thumb_key)

Cold starts hurt here if traffic is bursty. Provisioned concurrency costs money but keeps functions warm. For high-volume steady workloads, consider keeping a small pool ready.

The failure mode is simple: if a function fails, the event goes back to the queue or stream and retries. Set a dead-letter queue to capture poison messages after a few attempts.

Pattern 2: API Gateway + Lambda Backend

REST or HTTP APIs backed by functions. Each route maps to a Lambda, or one function handles multiple routes with path-based routing.

API Gateway handles rate limiting, auth, request validation, and CORS before your code runs. Lambda executes only the business logic. This pattern replaced dozens of small microservices for me on projects where traffic was unpredictable.

exports.handler = async (event) => {
    const path = event.rawPath;
    const method = event.requestContext.http.method;

    if (path === '/users' && method === 'GET') {
        return {
            statusCode: 200,
            body: JSON.stringify({ users: await getUsers() })
        };
    }

    if (path.startsWith('/users/') && method === 'GET') {
        const userId = path.split('/')[2];
        return {
            statusCode: 200,
            body: JSON.stringify(await getUser(userId))
        };
    }

    return { statusCode: 404, body: 'Not Found' };
};

Watch the response size limit—Lambda has a 6 MB payload cap, API Gateway adds its own. For large responses, stream from S3 or use pagination.

Cold starts matter more here because users wait. Optimize your runtime: lighter dependencies, lazy loading, connection pooling outside the handler. Go and Rust start faster than Python and Node for small functions.

One team I worked with cut their infrastructure cost by 70% moving from always-on containers to this pattern, because their traffic spiked during business hours and dropped to near-zero overnight.

Pattern 3: Scheduled Jobs (Cron Functions)

Replace cron jobs on servers with EventBridge schedules triggering Lambdas. Cleanup tasks, report generation, data sync—anything that runs on a timer.

The setup is cleaner than crontab syntax, the logs are centralized, and you don't maintain a server just to run a 30-second script once a day.

CleanupFunction:
  Type: AWS::Lambda::Function
  Properties:
    Runtime: python3.11
    Handler: cleanup.handler
    Timeout: 300

CleanupSchedule:
  Type: AWS::Events::Rule
  Properties:
    ScheduleExpression: "cron(0 2 * * ? *)"  # 2 AM UTC daily
    Targets:
      - Arn: !GetAtt CleanupFunction.Arn
        Id: CleanupTarget

I moved a dozen daily ETL jobs from a dedicated EC2 instance to scheduled Lambdas. Cost dropped from $50/month to under $5, and I stopped getting paged when the instance ran out of disk.

For long-running jobs beyond the 15-minute Lambda limit, use Step Functions to chain multiple invocations or switch to Fargate tasks triggered by EventBridge.

Pattern 4: Stream Processing

Kinesis or DynamoDB Streams push records to Lambda in batches. Real-time analytics, change data capture, event sourcing—any workload that processes ordered events.

The function receives a batch of records, processes them, and checkpoints progress automatically. If processing fails, the batch retries from the last checkpoint.

def lambda_handler(event, context):
    for record in event['Records']:
        if record['eventName'] == 'INSERT':
            new_item = record['dynamodb']['NewImage']
            user_id = new_item['userId']['S']

            # Update analytics aggregates
            update_user_stats(user_id)

        elif record['eventName'] == 'MODIFY':
            old_item = record['dynamodb']['OldImage']
            new_item = record['dynamodb']['NewImage']

            # Detect changes and trigger downstream actions
            if old_item['status']['S'] != new_item['status']['S']:
                send_status_notification(new_item)

Batch size and window matter. Small batches mean more invocations and higher cost. Large batches risk partial failures and longer retry delays. Start with 100 records or a 5-second window, tune from there.

Ordering guarantees are per shard. If you need global ordering, use a single shard (limits throughput) or design your aggregations to be commutative.

Pattern 5: Fan-Out Workflows

One event triggers multiple downstream functions in parallel. SNS topic receives a message, fans out to several Lambda subscribers. Each subscriber does independent work.

This is perfect for notifications. A user signs up, you send a welcome email, provision their account, log to analytics, and update a CRM. All in parallel, all independent.

UserSignupTopic:
  Type: AWS::SNS::Topic

EmailFunction:
  Type: AWS::Lambda::Function
  # Sends welcome email

ProvisionFunction:
  Type: AWS::Lambda::Function
  # Creates user workspace

AnalyticsFunction:
  Type: AWS::Lambda::Function
  # Logs signup event

EmailSubscription:
  Type: AWS::SNS::Subscription
  Properties:
    TopicArn: !Ref UserSignupTopic
    Protocol: lambda
    Endpoint: !GetAtt EmailFunction.Arn

The publisher doesn't wait for subscribers. It fires and forgets. If one subscriber fails, the others still succeed.

I used this pattern for a multi-tenant SaaS where new tenant creation triggered six separate setup functions—database provisioning, subdomain creation, template copying, billing setup, onboarding emails, and monitoring config. Total wall-clock time dropped from 3 minutes serial to 30 seconds parallel.

Pattern 6: Choreography vs Orchestration

Two ways to coordinate multi-step workflows. Choreography: each service listens for events and decides what to do next. Orchestration: a central coordinator tells each service what to do.

Choreography is loosely coupled. An order is placed, the payment service listens and charges the card, the inventory service listens and reserves stock, the shipping service listens and schedules delivery. No central brain.

Orchestration uses Step Functions or a similar state machine. The coordinator calls payment, waits, calls inventory, waits, calls shipping. Explicit control flow, easier to visualize and debug.

I prefer choreography for simple workflows where services are truly independent. Three steps or fewer, no complex branching, no compensation logic. Event-driven scaling is cleaner.

For anything complex—parallel branches, retries with exponential backoff, human approval steps, rollback on failure—orchestration wins. Step Functions give you a visual workflow, automatic retries, and distributed saga patterns.

{
  "StartAt": "ProcessPayment",
  "States": {
    "ProcessPayment": {
      "Type": "Task",
      "Resource": "arn:aws:lambda:region:account:function:ProcessPayment",
      "Catch": [
        {
          "ErrorEquals": ["PaymentFailed"],
          "Next": "PaymentFailureHandler"
        }
      ],
      "Next": "ReserveInventory"
    },
    "ReserveInventory": {
      "Type": "Task",
      "Resource": "arn:aws:lambda:region:account:function:ReserveInventory",
      "Next": "ScheduleShipping"
    },
    "ScheduleShipping": {
      "Type": "Task",
      "Resource": "arn:aws:lambda:region:account:function:ScheduleShipping",
      "End": true
    }
  }
}

The cost model differs. Choreography pays per Lambda invocation. Orchestration adds Step Functions state transitions on top. For high-volume simple flows, choreography is cheaper. For low-volume complex flows, orchestration is worth it.

Pattern 7: Hybrid Serverless-Container

Not everything fits in a 15-minute function. ML inference, video transcoding, large batch jobs—these need more memory, longer runtimes, or GPU access.

The hybrid pattern uses Lambda for coordination and Fargate or ECS for heavy lifting. Lambda receives the request, validates input, starts a Fargate task with the work payload, and returns immediately. The task runs as long as needed, writes results to S3, and publishes a completion event.

import boto3
import json

ecs = boto3.client('ecs')

def lambda_handler(event, context):
    job_id = event['job_id']
    input_bucket = event['input_bucket']
    input_key = event['input_key']

    response = ecs.run_task(
        cluster='processing-cluster',
        launchType='FARGATE',
        taskDefinition='video-transcode:1',
        overrides={
            'containerOverrides': [
                {
                    'name': 'transcoder',
                    'environment': [
                        {'name': 'JOB_ID', 'value': job_id},
                        {'name': 'INPUT_BUCKET', 'value': input_bucket},
                        {'name': 'INPUT_KEY', 'value': input_key}
                    ]
                }
            ]
        },
        networkConfiguration={
            'awsvpcConfiguration': {
                'subnets': ['subnet-abc123'],
                'assignPublicIp': 'ENABLED'
            }
        }
    )

    return {
        'statusCode': 202,
        'body': json.dumps({'job_id': job_id, 'status': 'processing'})
    }

This pattern gives you the fast response time of Lambda with the flexibility of containers. The API stays responsive, the heavy work runs on infrastructure that fits the job.

One customer I supported ran ML model inference this way. API Gateway + Lambda validated requests and queued them. Fargate tasks with GPU pulled from the queue, ran inference, and uploaded results. Cost was 60% lower than keeping GPU instances running 24/7, and latency for users stayed under 200ms because Lambda returned immediately.

What if cold starts kill your SLA?

Provisioned concurrency keeps functions warm, but it costs money—you pay for idle capacity. Reserve it for latency-critical paths only.

For bursty traffic, scale provisioned concurrency with Application Auto Scaling based on a schedule or CloudWatch metric. Ramp up before peak hours, scale down overnight.

If your runtime is slow to initialize, optimize dependencies. Use Lambda layers for shared libraries, lazy-load heavy imports, and reuse connections across invocations.

Sometimes the answer is a different runtime. Go and Rust start faster than Python and Java for small functions. Node is a middle ground.

Picking the right pattern

Start with the simplest pattern that solves the problem. Event-driven processing for asynchronous work. API Gateway + Lambda for synchronous requests. Scheduled jobs for cron.

Add complexity only when needed. Orchestration when choreography gets messy. Containers when Lambda limits hurt. Provisioned concurrency when cold starts matter.

I've seen teams over-engineer from day one—Step Functions for a two-step workflow, provisioned concurrency on every function, hybrid patterns when pure serverless would work. Start simple, measure, optimize where it hurts.

The serverless promise is real: zero ops overhead, automatic scaling, pay-per-use pricing. But the architecture has to fit. Choose patterns that match your workload, monitor cold starts and costs, and adjust as you learn.

Common questions about serverless patterns

When should I use orchestration instead of choreography?
When you need explicit error handling, compensation logic, or a visual workflow. Three steps or fewer with independent services, use choreography. Anything more complex, orchestration.

How do I handle long-running jobs beyond 15 minutes?
Chain multiple Lambda invocations with Step Functions, or offload to Fargate tasks. The hybrid pattern works well for batch processing and ML workloads.

What's the best way to reduce cold start latency?
Optimize your runtime first—lighter dependencies, lazy imports, connection reuse. If that's not enough, use provisioned concurrency on critical paths. Consider Go or Rust for faster startup.

Can I mix serverless and traditional servers?
Absolutely. The hybrid pattern is common—Lambda for coordination, containers or VMs for heavy lifting. Use the right tool for each job.

How do I debug distributed serverless workflows?
Centralized logging and distributed tracing. CloudWatch Logs captures function output, X-Ray traces requests across services. Add correlation IDs to track a single transaction through multiple functions.

Patterns scale when they fit

Serverless isn't a silver bullet. The patterns I've shown work for specific workloads—event processing, APIs, scheduled jobs, streams, fan-outs, workflows, and hybrid designs.

Pick the pattern that matches your traffic shape and latency requirements. Monitor cold starts, tune batch sizes, and add provisioned concurrency only where needed. The architecture matters more than the hype.