...
...
#serverless #aws #lambda #cloud #architecture

Serverless Architecture Patterns: Scalable Apps Guide 2025

Learn serverless architecture patterns with AWS Lambda, Azure Functions & Google Cloud. Event-driven design & scalability strategies.

V
VooStack Team
October 2, 2025
16 min read

Serverless Architecture Patterns: Building Scalable Apps Without Managing Servers

Serverless gets marketed as “never think about infrastructure again!” which is complete nonsense. You absolutely think about infrastructure—you just think about it differently. And when you get it right, serverless is incredible. When you get it wrong, you’ll wish you’d just deployed a simple Node.js server.

I’ve built serverless apps handling millions of requests daily, and I’ve debugged nightmarish cold start issues at 2 AM. Let me show you what actually works.

What Serverless Actually Means

Serverless doesn’t mean “no servers.” It means:

  • You don’t provision or manage servers
  • You pay only for what you use
  • Scaling is automatic
  • You write functions, not long-running processes

The Good:

  • Zero idle cost (pay per request)
  • Infinite scalability (theoretically)
  • No server maintenance
  • Fast iteration

The Bad:

  • Cold starts (sometimes)
  • Vendor lock-in
  • Debugging is harder
  • Cost can spiral with high traffic

When Serverless Makes Sense

Perfect Use Cases

APIs with Variable Traffic:

Traffic: 100 req/day → 10,000 req/day → 100 req/day
Traditional: Pay for peak capacity 24/7
Serverless: Pay only for actual requests

Event-Driven Workflows:

  • Image processing on upload
  • Email sending on user signup
  • Data processing on S3 file upload
  • Scheduled tasks (cron jobs)

Microservices with Clear Boundaries:

  • Authentication service
  • Payment processing
  • Notification delivery
  • Report generation

Bad Use Cases

Long-Running Processes: Lambda has a 15-minute max execution. Don’t use it for:

  • Video encoding (use ECS/Batch)
  • Large data migrations
  • Machine learning training

Consistent High Traffic: If you’re handling 10,000 req/sec 24/7, traditional servers are cheaper. Do the math.

Low Latency Requirements: Cold starts can add 1-3 seconds. If every millisecond counts, serverless might not work.

Core AWS Serverless Services

Lambda: The Foundation

// Basic Lambda handler
export const handler = async (event, context) => {
  try {
    const body = JSON.parse(event.body);

    // Your business logic
    const result = await processData(body);

    return {
      statusCode: 200,
      headers: {
        'Content-Type': 'application/json',
        'Access-Control-Allow-Origin': '*',
      },
      body: JSON.stringify(result),
    };
  } catch (error) {
    console.error('Error:', error);

    return {
      statusCode: 500,
      body: JSON.stringify({ error: error.message }),
    };
  }
};

Key Limits:

  • Max execution time: 15 minutes
  • Max memory: 10GB
  • Max deployment package: 250MB (unzipped)
  • Max concurrent executions: 1000 (default, can increase)

API Gateway: The HTTP Layer

# Serverless Framework configuration
service: my-api

provider:
  name: aws
  runtime: nodejs20.x
  region: us-east-1

functions:
  getUser:
    handler: src/handlers/users.get
    events:
      - http:
          path: users/{id}
          method: get
          cors: true

  createUser:
    handler: src/handlers/users.create
    events:
      - http:
          path: users
          method: post
          cors: true
          authorizer:
            name: authorizer
            resultTtlInSeconds: 300

  authorizer:
    handler: src/handlers/auth.verify

DynamoDB: The Database

import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, GetCommand, PutCommand } from '@aws-sdk/lib-dynamodb';

const client = new DynamoDBClient({});
const docClient = DynamoDBDocumentClient.from(client);

// Get item
export const getUser = async (userId) => {
  const command = new GetCommand({
    TableName: 'Users',
    Key: { userId },
  });

  const { Item } = await docClient.send(command);
  return Item;
};

// Put item
export const createUser = async (user) => {
  const command = new PutCommand({
    TableName: 'Users',
    Item: {
      userId: user.id,
      email: user.email,
      createdAt: new Date().toISOString(),
      ...user,
    },
  });

  await docClient.send(command);
  return user;
};

// Query with index
export const getUserByEmail = async (email) => {
  const command = new QueryCommand({
    TableName: 'Users',
    IndexName: 'EmailIndex',
    KeyConditionExpression: 'email = :email',
    ExpressionAttributeValues: {
      ':email': email,
    },
  });

  const { Items } = await docClient.send(command);
  return Items[0];
};

DynamoDB Best Practices:

  • Design for access patterns, not normalization
  • Use single-table design for related entities
  • Provision indexes carefully (they cost money)
  • Use batch operations when possible

Architectural Patterns

Pattern 1: API Backend

Classic REST API built with Lambda + API Gateway:

API Gateway
    ├── GET /users → Lambda: getUsers → DynamoDB
    ├── POST /users → Lambda: createUser → DynamoDB
    ├── GET /users/{id} → Lambda: getUser → DynamoDB
    └── PUT /users/{id} → Lambda: updateUser → DynamoDB

Implementation:

// src/handlers/users.js
import { getUser, createUser, updateUser, listUsers } from '../services/userService';

export const get = async (event) => {
  const { id } = event.pathParameters;
  const user = await getUser(id);

  if (!user) {
    return {
      statusCode: 404,
      body: JSON.stringify({ error: 'User not found' }),
    };
  }

  return {
    statusCode: 200,
    body: JSON.stringify(user),
  };
};

export const create = async (event) => {
  const data = JSON.parse(event.body);
  const user = await createUser(data);

  return {
    statusCode: 201,
    body: JSON.stringify(user),
  };
};

export const list = async (event) => {
  const { limit = 20, nextToken } = event.queryStringParameters || {};
  const result = await listUsers(limit, nextToken);

  return {
    statusCode: 200,
    body: JSON.stringify(result),
  };
};

Pattern 2: Event-Driven Processing

S3 uploads trigger Lambda processing:

functions:
  processImage:
    handler: src/handlers/images.process
    events:
      - s3:
          bucket: uploaded-images
          event: s3:ObjectCreated:*
          rules:
            - prefix: uploads/
            - suffix: .jpg

Handler:

import sharp from 'sharp';
import { S3Client, GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';

const s3 = new S3Client({});

export const process = async (event) => {
  for (const record of event.Records) {
    const bucket = record.s3.bucket.name;
    const key = record.s3.object.key;

    // Get original image
    const getCommand = new GetObjectCommand({ Bucket: bucket, Key: key });
    const { Body } = await s3.send(getCommand);

    // Process image
    const thumbnail = await sharp(Body)
      .resize(200, 200, { fit: 'cover' })
      .jpeg({ quality: 80 })
      .toBuffer();

    // Save thumbnail
    const putCommand = new PutObjectCommand({
      Bucket: bucket,
      Key: key.replace('uploads/', 'thumbnails/'),
      Body: thumbnail,
      ContentType: 'image/jpeg',
    });

    await s3.send(putCommand);
  }
};

Pattern 3: Queue-Based Processing

SQS ensures reliable async processing:

functions:
  sendEmail:
    handler: src/handlers/email.send
    events:
      - sqs:
          arn: !GetAtt EmailQueue.Arn
          batchSize: 10

resources:
  Resources:
    EmailQueue:
      Type: AWS::SQS::Queue
      Properties:
        QueueName: EmailQueue
        VisibilityTimeout: 300  # 5 minutes
        RedrivePolicy:
          deadLetterTargetArn: !GetAtt EmailDLQ.Arn
          maxReceiveCount: 3

    EmailDLQ:
      Type: AWS::SQS::Queue
      Properties:
        QueueName: EmailDLQ

Producer (adds to queue):

import { SQSClient, SendMessageCommand } from '@aws-sdk/client-sqs';

const sqs = new SQSClient({});

export const queueEmail = async (to, subject, body) => {
  const command = new SendMessageCommand({
    QueueUrl: process.env.EMAIL_QUEUE_URL,
    MessageBody: JSON.stringify({ to, subject, body }),
  });

  await sqs.send(command);
};

Consumer (processes queue):

export const send = async (event) => {
  for (const record of event.Records) {
    const { to, subject, body } = JSON.parse(record.body);

    try {
      await sendEmail(to, subject, body);
    } catch (error) {
      console.error('Failed to send email:', error);
      throw error;  // Message goes to DLQ after retries
    }
  }
};

Pattern 4: Step Functions for Workflows

Complex workflows with state management:

stepFunctions:
  stateMachines:
    orderProcessing:
      definition:
        StartAt: ValidateOrder
        States:
          ValidateOrder:
            Type: Task
            Resource: !GetAtt ValidateOrderFunction.Arn
            Next: CheckInventory

          CheckInventory:
            Type: Task
            Resource: !GetAtt CheckInventoryFunction.Arn
            Next: ChargePayment
            Catch:
              - ErrorEquals: [OutOfStock]
                Next: NotifyOutOfStock

          ChargePayment:
            Type: Task
            Resource: !GetAtt ChargePaymentFunction.Arn
            Next: FulfillOrder
            Catch:
              - ErrorEquals: [PaymentFailed]
                Next: NotifyPaymentFailed

          FulfillOrder:
            Type: Task
            Resource: !GetAtt FulfillOrderFunction.Arn
            End: true

          NotifyOutOfStock:
            Type: Task
            Resource: !GetAtt NotifyFunction.Arn
            End: true

          NotifyPaymentFailed:
            Type: Task
            Resource: !GetAtt NotifyFunction.Arn
            End: true

Start execution:

import { SFNClient, StartExecutionCommand } from '@aws-sdk/client-sfn';

const sfn = new SFNClient({});

export const startOrderProcessing = async (orderId) => {
  const command = new StartExecutionCommand({
    stateMachineArn: process.env.STATE_MACHINE_ARN,
    input: JSON.stringify({ orderId }),
  });

  const { executionArn } = await sfn.send(command);
  return executionArn;
};

Optimizing Cold Starts

Cold starts are the #1 serverless complaint. Here’s how to minimize them:

Keep Functions Small

// Bad - huge bundle
import _ from 'lodash';  // 70KB
import moment from 'moment';  // 67KB
import axios from 'axios';  // 13KB

// Good - minimal dependencies
import { chunk } from 'lodash-es/chunk';  // Tree-shakeable
import { parseISO } from 'date-fns';  // Small, modular

// Or just use native JS
const chunk = (arr, size) => Array.from(
  { length: Math.ceil(arr.length / size) },
  (_, i) => arr.slice(i * size, i * size + size)
);

Use Provisioned Concurrency

functions:
  api:
    handler: handler.api
    provisionedConcurrency: 5  # Always keep 5 warm

Cost: You pay for provisioned instances 24/7, but they’re always warm.

ARM Processors (Graviton)

provider:
  name: aws
  runtime: nodejs20.x
  architecture: arm64  # 20% cheaper, often faster

Connection Pooling

// Bad - creates new DB connection per invocation
export const handler = async (event) => {
  const db = await createConnection();  // Cold start penalty
  const result = await db.query('SELECT * FROM users');
  return result;
};

// Good - reuse connection across invocations
let db;

export const handler = async (event) => {
  if (!db) {
    db = await createConnection();
  }
  const result = await db.query('SELECT * FROM users');
  return result;
};

Cost Optimization

Right-Size Memory

Lambda CPU scales with memory. More memory = faster execution = lower cost (sometimes).

Test different memory settings:

functions:
  process:
    handler: handler.process
    memorySize: 1024  # Start here, then test 512, 2048, 3008

I’ve seen 2048MB be cheaper than 1024MB because execution time halved.

Use Lambda Layers

Share code between functions without duplicating:

layers:
  commonDeps:
    path: layers/common
    compatibleRuntimes:
      - nodejs20.x

functions:
  func1:
    handler: handler1.main
    layers:
      - { Ref: CommonDepsLambdaLayer }

  func2:
    handler: handler2.main
    layers:
      - { Ref: CommonDepsLambdaLayer }

layers/common/nodejs/package.json:

{
  "dependencies": {
    "aws-sdk": "^3.0.0",
    "lodash": "^4.17.21"
  }
}

Batch Operations

// Bad - one Lambda per item
for (const item of items) {
  await lambda.invoke({
    FunctionName: 'process',
    Payload: JSON.stringify(item),
  });
}

// Good - batch items
const batches = chunk(items, 100);
for (const batch of batches) {
  await lambda.invoke({
    FunctionName: 'processBatch',
    Payload: JSON.stringify(batch),
  });
}

Monitoring and Debugging

CloudWatch Logs

export const handler = async (event) => {
  console.log('Event:', JSON.stringify(event, null, 2));

  try {
    const result = await processEvent(event);
    console.log('Success:', result);
    return result;
  } catch (error) {
    console.error('Error:', error);
    throw error;
  }
};

Structured Logging:

const log = (level, message, data = {}) => {
  console.log(JSON.stringify({
    level,
    message,
    timestamp: new Date().toISOString(),
    requestId: context.requestId,
    ...data,
  }));
};

export const handler = async (event, context) => {
  log('info', 'Processing event', { eventType: event.type });

  try {
    const result = await process(event);
    log('info', 'Success', { result });
    return result;
  } catch (error) {
    log('error', 'Failed', { error: error.message, stack: error.stack });
    throw error;
  }
};

X-Ray Tracing

import AWSXRay from 'aws-xray-sdk-core';
import AWS from 'aws-sdk';

const dynamodb = AWSXRay.captureAWSClient(new AWS.DynamoDB.DocumentClient());

export const handler = async (event) => {
  // Automatically traced
  const user = await dynamodb.get({
    TableName: 'Users',
    Key: { id: event.userId },
  }).promise();

  return user;
};

Lambda Insights

functions:
  api:
    handler: handler.api
    layers:
      - arn:aws:lambda:us-east-1:580247275435:layer:LambdaInsightsExtension:21
    environment:
      LAMBDA_INSIGHTS_LOG_LEVEL: info

Provides memory, CPU, network metrics.

Testing Serverless

Local Testing

// tests/users.test.js
import { handler } from '../src/handlers/users';

describe('GET /users/{id}', () => {
  it('returns user', async () => {
    const event = {
      pathParameters: { id: '123' },
      httpMethod: 'GET',
    };

    const response = await handler(event);

    expect(response.statusCode).toBe(200);
    expect(JSON.parse(response.body)).toMatchObject({
      id: '123',
      email: 'test@example.com',
    });
  });

  it('returns 404 for missing user', async () => {
    const event = {
      pathParameters: { id: 'nonexistent' },
      httpMethod: 'GET',
    };

    const response = await handler(event);

    expect(response.statusCode).toBe(404);
  });
});

Integration Testing

# serverless-offline for local API
npm install --save-dev serverless-offline

# serverless.yml
plugins:
  - serverless-offline

# Run locally
npx serverless offline

Load Testing

import { Lambda } from '@aws-sdk/client-lambda';

const lambda = new Lambda({});

async function loadTest() {
  const promises = [];

  for (let i = 0; i < 1000; i++) {
    promises.push(
      lambda.invoke({
        FunctionName: 'my-function',
        Payload: JSON.stringify({ test: i }),
      })
    );
  }

  const results = await Promise.all(promises);
  console.log('Success:', results.filter(r => !r.FunctionError).length);
}

loadTest();

Common Pitfalls

Ignoring Concurrency Limits

Default limit is 1000 concurrent executions per region. Sudden traffic spike? Throttled.

Solution: Request limit increase or use reserved concurrency.

Not Handling Timeouts

Lambda times out at max 15 minutes. Long process? It will fail.

Solution: Break into smaller chunks or use Step Functions.

Storing State in /tmp

/tmp survives across invocations (maybe), but isn’t guaranteed.

Solution: Use S3, DynamoDB, or ElastiCache for state.

Circular Dependencies

Lambda → DynamoDB → DynamoDB Stream → Lambda → DynamoDB

This creates infinite loops. Been there, AWS bill was $10k.

Solution: Add guards, use TTLs, monitor recursion.

Conclusion

Serverless isn’t a silver bullet. It’s a powerful tool that works brilliantly for certain workloads and fails miserably for others.

Use serverless when:

  • Traffic is variable or unpredictable
  • You want to focus on code, not infrastructure
  • You need instant scalability
  • Cost optimization matters (low/variable traffic)

Avoid serverless when:

  • You have consistent high traffic (cheaper to use containers)
  • You need sub-100ms latency consistently
  • You have long-running processes (>15 minutes)
  • Your team lacks cloud experience

Start small. Build one serverless function. Learn the patterns. Understand the costs. Then decide if it’s right for your use case.

Considering serverless for your next project? VooStack has extensive experience building and optimizing serverless applications on AWS. Let’s discuss your architecture.

Topics

serverless aws lambda cloud architecture
V

Written by VooStack Team

Contact author

Share this article