Skip to content
Back to Blog
Performance11 min read

Serverless Architecture Patterns: 7 Designs That Scale

Seven proven serverless patterns—event-driven processing, API gateways, scheduled jobs, stream processing, fan-out workflows, orchestration strategies, and hybrid designs—that handle real production load.

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

Serverless compute sounds great until you're staring at cold start latency or debugging a fan-out that consumed your entire monthly budget in three hours. The pattern you choose makes the difference between a system that scales gracefully and one that melts down under moderate load.

I've migrated monolithic hosting control panels to serverless backends and watched event streams process millions of DNS zone updates without breaking a sweat. I've also seen naive implementations where every API call spawned twenty Lambda invocations because nobody planned the request flow. The architecture matters more than the runtime.

Below are seven patterns that show up in production systems—what they solve, when they break, and how to avoid the common traps.

Pattern 1: Event-driven processing

Your application reacts to events instead of polling for changes. An S3 upload triggers image resizing. A database insert fires a notification pipeline. A file drop in a bucket starts a multi-step encoding job.

The event source (S3, DynamoDB Streams, EventBridge, SQS) invokes your function directly. No cron job checking every minute. No worker process sleeping between polls. The cloud provider handles the wiring and retries.

In support work I handled, the usual mistake was forgetting idempotency. Events can arrive more than once. Your function receives the same S3 object notification twice, processes it twice, charges the customer twice. Guard every handler with a check: "Have I seen this event ID before?"

Use DynamoDB or Redis to track processed event IDs with a TTL. Check the ID before doing work. If it exists, return success immediately.

import boto3
import os

dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table(os.environ['IDEMPOTENCY_TABLE'])

def lambda_handler(event, context):
    event_id = event['Records'][0]['eventID']

    # Check if already processed
    response = table.get_item(Key={'eventId': event_id})
    if 'Item' in response:
        return {'statusCode': 200, 'body': 'Already processed'}

    # Do the actual work here
    process_s3_event(event)

    # Mark as processed with 24-hour TTL
    table.put_item(Item={
        'eventId': event_id,
        'timestamp': int(time.time()),
        'ttl': int(time.time()) + 86400
    })

    return {'statusCode': 200}

Event-driven patterns shine when you have unpredictable spikes. A customer uploads five thousand files at 3 AM. Traditional workers would queue them and process sequentially. Serverless spawns hundreds of concurrent executions and finishes in minutes.

Pattern 2: API Gateway + Lambda for HTTP APIs

API Gateway sits in front of your Lambda functions and handles the HTTP layer: routing, throttling, authentication, request validation. Lambda runs your business logic. This is the most common serverless web pattern.

You get automatic scaling, built-in DDoS protection, and pay-per-request pricing. For low-traffic APIs, this costs pennies. For high-traffic, you'll want to cache responses at the Gateway layer and use provisioned concurrency to eliminate cold starts on critical paths.

The biggest mistake is putting too much logic in a single Lambda. One function handling GET, POST, PUT, DELETE across multiple resources becomes a deployment bottleneck and a debugging nightmare. Split by resource and method.

GET  /users         → lambda-users-list
POST /users         → lambda-users-create  
GET  /users/{id}    → lambda-users-get
GET  /domains       → lambda-domains-list

Each function owns one action. You can deploy, test, and optimize independently. When the users-list function needs a rewrite, domains-list keeps running.

For authentication, use API Gateway authorizers instead of handling JWT validation in every function. The authorizer Lambda runs once per token (with caching) and returns an IAM policy. Subsequent requests with the same token skip re-authorization.

def authorizer_handler(event, context):
    token = event['authorizationToken']

    # Validate JWT (pseudo-code)
    if not valid_jwt(token):
        raise Exception('Unauthorized')

    user_id = extract_user_id(token)

    return {
        'principalId': user_id,
        'policyDocument': {
            'Version': '2012-10-17',
            'Statement': [{
                'Action': 'execute-api:Invoke',
                'Effect': 'Allow',
                'Resource': event['methodArn']
            }]
        },
        'context': {'userId': user_id}  # Passed to downstream Lambdas
    }

Cache the authorizer response for five or ten minutes. The token validation runs once, then a thousand requests sail through.

Pattern 3: Scheduled jobs (serverless cron)

EventBridge (formerly CloudWatch Events) triggers Lambda on a schedule. This replaces traditional cron jobs. Check SSL certificate expiry every day at 8 AM. Generate monthly billing reports on the first of each month. Archive old logs every Sunday.

The schedule expression looks like cron but uses AWS syntax:

rate(5 minutes)
rate(1 day)
cron(0 8 * * ? *)        # 8 AM UTC daily
cron(0 0 1 * ? *)        # First of every month

Scheduled Lambdas run in a single instance by default. If your job takes twenty minutes and fires every fifteen minutes, you'll have overlapping executions. Guard with a DynamoDB lock or use Step Functions to prevent concurrent runs.

import boto3
import time
from botocore.exceptions import ClientError

dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table('job-locks')

def lambda_handler(event, context):
    job_name = 'ssl-cert-check'

    try:
        # Acquire lock
        table.put_item(
            Item={
                'jobName': job_name,
                'locked': True,
                'timestamp': int(time.time())
            },
            ConditionExpression='attribute_not_exists(jobName)'
        )
    except ClientError as e:
        if e.response['Error']['Code'] == 'ConditionalCheckFailedException':
            print('Job already running, exiting')
            return
        raise

    try:
        # Do the work
        check_all_certificates()
    finally:
        # Release lock
        table.delete_item(Key={'jobName': job_name})

For complex schedules or long-running jobs, Step Functions state machines give you better visibility and built-in retry logic.

Pattern 4: Stream processing

Kinesis or DynamoDB Streams feed a continuous flow of records into Lambda. Each batch contains multiple records. Lambda processes them, checkpoints progress, and moves to the next batch.

This pattern handles log aggregation, real-time analytics, change data capture, and event sourcing. A DynamoDB table tracks user sessions. Every insert, update, or delete flows through a stream. Lambda maintains an aggregate view in ElastiCache or writes metrics to CloudWatch.

Streams guarantee ordering within a shard but not across shards. If you need strict global ordering, use a single shard (which limits throughput). For most use cases, per-user or per-tenant ordering is enough.

The Lambda receives a batch of records:

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

            # Update aggregates, send notifications, etc.
            increment_user_action_count(user_id, action)

    return {'batchItemFailures': []}  # Empty = all succeeded

If processing fails partway through a batch, return the failed record sequence numbers in batchItemFailures. Lambda will retry those records while letting the others commit. Don't fail the whole batch because one record is malformed.

Stream processing shines when you need sub-second latency. A user changes their email address. Within milliseconds, the stream processor invalidates cached sessions, updates search indexes, and triggers a verification email.

Pattern 5: Fan-out workflows

One event triggers many parallel tasks. A video upload fans out to thumbnail generation, multiple resolution transcodes, caption extraction, and metadata indexing. Each task runs independently. Some finish in seconds. Others take minutes.

SNS fan-out is the simplest approach. Publish one message to an SNS topic. Multiple SQS queues subscribe to the topic. Each queue feeds a different Lambda function.

[Upload Event] → SNS Topic → ├─ SQS (thumbnails)  → Lambda
                             ├─ SQS (1080p)        → Lambda  
                             ├─ SQS (720p)         → Lambda
                             └─ SQS (captions)     → Lambda

Each function scales independently. Thumbnail generation is fast and can handle high concurrency. 1080p encoding is slow, so Lambda spins up fewer instances and processes them sequentially. The system self-balances.

For workflows with dependencies ("don't send the email until transcoding and thumbnails both finish"), use Step Functions. The state machine waits for all parallel branches to complete before proceeding.

{
  "Type": "Parallel",
  "Branches": [
    {"StartAt": "GenerateThumbnail", "States": {...}},
    {"StartAt": "Transcode1080p", "States": {...}},
    {"StartAt": "Transcode720p", "States": {...}}
  ],
  "Next": "SendCompletionEmail"
}

Watch your fan-out factor. Fanning one event into a hundred Lambda invocations is fine. Fanning into ten thousand will hit concurrency limits and burn through your budget.

Pattern 6: Choreography vs orchestration

Choreography: services react to events without a central coordinator. An order service publishes "OrderPlaced." Payment service listens, charges the card, publishes "PaymentSucceeded." Fulfillment service listens, ships the item, publishes "OrderShipped." Each service knows what events to listen for and what to do.

Orchestration: a state machine coordinates the workflow. Step Functions calls payment, waits for success, then calls fulfillment, waits again, then updates order status. The orchestrator knows the whole sequence.

Choreography scales beautifully and avoids single points of failure. Each service is autonomous. Adding a new step means adding a new subscriber. No one needs to update a central workflow definition. The downside is visibility. Debugging a failed order means tracing events across five services and three event buses. In production incidents I've debugged, finding which service dropped the event took longer than fixing the bug.

Orchestration gives you a visual workflow diagram, centralized error handling, and automatic retries. Step Functions state machines show you exactly where a workflow stalled. The cost is coupling—every change to the workflow means updating the state machine definition.

Use choreography for loosely coupled, independent actions: "New user signed up" triggers email, analytics, CRM updates. These can happen in any order. Some can fail without blocking others.

Use orchestration for strict sequences with dependencies: charge the card before shipping the product. You want the orchestrator to retry payment three times with exponential backoff, then send an alert if all attempts fail.

Hybrid patterns are common. A Step Functions state machine (orchestration) coordinates the core business logic. Each step publishes events (choreography) for auxiliary systems like logging, metrics, and notifications.

Pattern 7: Hybrid serverless + containers

Some workloads don't fit Lambda's fifteen-minute limit or need custom runtimes that are painful to package as a deployment zip. Video encoding, large ML model inference, and batch jobs that process gigabytes of data run better in containers.

Fargate tasks are serverless containers—no servers to patch, auto-scaling, pay-per-second billing. Lambda invokes ECS tasks for heavy lifting, then processes the results.

import boto3

ecs = boto3.client('ecs')

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

    # Start Fargate task to transcode video
    response = ecs.run_task(
        cluster='video-processing',
        taskDefinition='transcode-task:latest',
        launchType='FARGATE',
        networkConfiguration={
            'awsvpcConfiguration': {
                'subnets': ['subnet-abc123'],
                'assignPublicIp': 'ENABLED'
            }
        },
        overrides={
            'containerOverrides': [{
                'name': 'transcoder',
                'environment': [
                    {'name': 'INPUT_VIDEO', 'value': video_key},
                    {'name': 'OUTPUT_BUCKET', 'value': 'processed-videos'}
                ]
            }]
        }
    )

    task_arn = response['tasks'][0]['taskArn']
    return {'taskArn': task_arn}

The container runs, writes output to S3, and terminates. Total runtime might be two hours. Lambda only ran for a few seconds to launch the task.

Another hybrid pattern uses Lambda for API and business logic, RDS Proxy for database connections, and Aurora Serverless for the database. Lambda doesn't manage connection pools well (each invocation creates new connections). RDS Proxy multiplexes thousands of Lambda invocations onto a small pool of database connections.

Common questions

How do I handle secrets in serverless functions?
Use AWS Secrets Manager or Parameter Store. Read secrets once during cold start and cache them in a global variable outside the handler. Don't fetch the secret on every invocation.

What's a reasonable cold start time?
Node and Python cold starts are typically 200-500ms. Java and .NET can take 2-5 seconds. If your API needs <100ms latency, use provisioned concurrency on critical functions.

Should I use one Lambda per function or bundle routes together?
Start with separate functions. Bundle them only if you're hitting deployment or API Gateway route limits. Monolithic Lambdas lose the main benefit of serverless: independent scaling and deployment.

How do I test serverless workflows locally?
LocalStack and SAM CLI emulate AWS services locally. For integration tests, deploy to a dev account and use real AWS services. Local emulation catches syntax errors but misses IAM permission issues and service-specific quirks.

What breaks first

Most serverless failures come from three places: concurrency limits, retries without backoff, and missing idempotency. Lambda has account-level concurrency limits (default 1000 concurrent executions). A sudden traffic spike exhausts the limit and new invocations get throttled. Request a limit increase before you go viral.

Retries amplify problems. An event triggers a Lambda that calls an external API. The API times out. Lambda retries. Now you have two calls timing out. EventBridge retries the whole event. Four calls. DLQs fill up. Always add exponential backoff and jitter to retries. Always set a maximum retry count.

Idempotency guards every entry point. Events arrive twice. Users click submit twice. Networks replay packets. If your function charges a credit card or sends an email, make sure it only happens once per logical operation. A DynamoDB conditional write or a unique transaction ID saves you from billing nightmares and angry customers.

Serverless architectures scale effortlessly until they don't. The pattern you choose determines whether your system handles a ten-times load increase or falls over at 2x. Pick the pattern that matches your event flow, test the failure paths, and guard the edges with idempotency checks.