Back to blog

Serverless & FaaS Architecture: Build Without Managing Servers

software-architectureserverlesscloudbackendsystem-design
Serverless & FaaS Architecture: Build Without Managing Servers

Every architecture pattern we've covered so far assumes you're managing servers — whether it's a monolith on one machine, microservices across a cluster, or containers orchestrated by Kubernetes. You provision capacity, handle scaling, patch operating systems, and pay for idle time.

Serverless architecture eliminates that operational burden. You write functions, deploy them, and the cloud provider handles everything else: provisioning, scaling, patching, and billing. You pay only for what you use — per request, per millisecond of execution.

But "serverless" doesn't mean "no servers." It means you don't think about servers. The servers still exist — they're just someone else's problem.

In this post, we'll cover:

✅ What serverless actually means (FaaS + BaaS)
✅ How FaaS works: AWS Lambda, Azure Functions, Google Cloud Functions, Vercel
✅ Cold starts and performance implications
✅ Stateless function design
✅ Event triggers: HTTP, queue, schedule, storage
✅ Serverless databases: Neon, PlanetScale, DynamoDB
✅ Composition patterns: chaining, fan-out/fan-in, orchestration
✅ Cost model: pay-per-invocation vs always-on
✅ Limitations: execution time, memory, vendor lock-in
✅ Serverless frameworks: SST, Serverless Framework, SAM
✅ When serverless makes sense vs traditional servers
✅ Hybrid architectures


What Is Serverless?

Serverless is an umbrella term for two categories:

Function-as-a-Service (FaaS)

FaaS lets you deploy individual functions that run in response to events. The platform manages the runtime, scaling, and infrastructure.

Examples: AWS Lambda, Azure Functions, Google Cloud Functions, Vercel Serverless Functions, Cloudflare Workers.

Backend-as-a-Service (BaaS)

BaaS provides fully managed backend services that you consume via APIs instead of building from scratch.

Examples:

  • Authentication: Auth0, Clerk, Firebase Auth, Neon Auth
  • Database: Neon (Postgres), PlanetScale (MySQL), DynamoDB, Supabase
  • Storage: S3, Cloudflare R2, Supabase Storage
  • Real-time: Pusher, Ably, Supabase Realtime
  • Email: Resend, SendGrid, SES

A serverless application typically combines FaaS (for custom logic) with BaaS (for managed infrastructure).


How FaaS Works

The Lifecycle of a Function Invocation

AWS Lambda Example

// handler.ts — A simple AWS Lambda function
import { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";
 
// Initialization code — runs ONCE per cold start
const dbClient = new DatabaseClient(process.env.DATABASE_URL!);
 
// Handler — runs on EVERY invocation
export const handler = async (
  event: APIGatewayProxyEvent
): Promise<APIGatewayProxyResult> => {
  const { httpMethod, path, body } = event;
 
  if (httpMethod === "GET" && path === "/api/users") {
    const users = await dbClient.query("SELECT * FROM users LIMIT 50");
    return {
      statusCode: 200,
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(users),
    };
  }
 
  if (httpMethod === "POST" && path === "/api/users") {
    const { name, email } = JSON.parse(body || "{}");
    const user = await dbClient.query(
      "INSERT INTO users (name, email) VALUES ($1, $2) RETURNING *",
      [name, email]
    );
    return {
      statusCode: 201,
      body: JSON.stringify(user),
    };
  }
 
  return { statusCode: 404, body: "Not Found" };
};

Vercel Serverless Functions (Next.js)

// app/api/users/route.ts — Next.js Route Handler (runs as serverless)
import { NextRequest, NextResponse } from "next/server";
import { neon } from "@neondatabase/serverless";
 
const sql = neon(process.env.DATABASE_URL!);
 
export async function GET(request: NextRequest) {
  const searchParams = request.nextUrl.searchParams;
  const limit = searchParams.get("limit") || "50";
 
  const users = await sql`SELECT * FROM users LIMIT ${limit}`;
 
  return NextResponse.json(users);
}
 
export async function POST(request: NextRequest) {
  const { name, email } = await request.json();
 
  const [user] = await sql`
    INSERT INTO users (name, email)
    VALUES (${name}, ${email})
    RETURNING *
  `;
 
  return NextResponse.json(user, { status: 201 });
}

Azure Functions

// src/functions/getUsers.ts
import { app, HttpRequest, HttpResponseInit } from "@azure/functions";
 
app.http("getUsers", {
  methods: ["GET"],
  authLevel: "anonymous",
  route: "users",
  handler: async (request: HttpRequest): Promise<HttpResponseInit> => {
    const users = await db.query("SELECT * FROM users");
    return {
      status: 200,
      jsonBody: users,
    };
  },
});

Cold Starts: The Serverless Tax

A cold start occurs when the platform needs to create a new container for your function. It includes:

  1. Provisioning a container/micro-VM
  2. Downloading your code package
  3. Initializing the runtime (Node.js, Python, Java, etc.)
  4. Running your initialization code (database connections, config loading)

Cold Start Comparison

RuntimeCold Start (typical)Notes
Node.js100-500msFast, small runtime
Python100-500msFast, similar to Node.js
Go50-200msCompiled binary, fastest
Rust50-200msCompiled binary, very fast
Java1-10sJVM startup, class loading
.NET500ms-3sCLR initialization

Strategies to Minimize Cold Starts

1. Keep functions small

// ❌ One giant function with many dependencies
import express from "express";
import mongoose from "mongoose";
import redis from "redis";
import { S3Client } from "@aws-sdk/client-s3";
import sharp from "sharp"; // Image processing — heavy!
// 50MB deployment package, slow cold start
 
// ✅ Focused function with minimal dependencies
import { neon } from "@neondatabase/serverless"; // ~200KB
// 2MB deployment package, fast cold start

2. Use provisioned concurrency (AWS Lambda)

# serverless.yml
functions:
  api:
    handler: handler.main
    provisionedConcurrency: 5  # 5 instances always warm

3. Initialize outside the handler

// ✅ Database client created ONCE, reused across invocations
const sql = neon(process.env.DATABASE_URL!);
 
export async function handler(event: any) {
  // sql is already initialized on warm starts
  const result = await sql`SELECT 1`;
  return { statusCode: 200, body: "OK" };
}

4. Choose fast runtimes for latency-sensitive functions

Go and Rust have the fastest cold starts. For Node.js, use lightweight dependencies and tree-shake unused code.


Stateless Function Design

Serverless functions must be stateless — each invocation is independent with no shared memory between calls.

// ❌ WRONG — Stateful: relies on in-memory state
let requestCount = 0;  // Resets on cold start, not shared across instances
 
export async function handler(event: any) {
  requestCount++;  // Unreliable! Different instances have different counts
  return { body: `Request #${requestCount}` };
}
// ✅ CORRECT — Stateless: uses external storage
import { Redis } from "@upstash/redis";
 
const redis = new Redis({
  url: process.env.UPSTASH_REDIS_URL!,
  token: process.env.UPSTASH_REDIS_TOKEN!,
});
 
export async function handler(event: any) {
  const count = await redis.incr("request-count");
  return { body: `Request #${count}` };
}

Where to store state:

State TypeSolutionExample
Session dataRedis (Upstash), JWT tokensUser login sessions
Persistent dataDatabase (Neon, DynamoDB)User profiles, orders
CacheRedis (Upstash), CDNAPI responses, computed values
File uploadsObject storage (S3, R2)Images, documents
Temporary stateStep Functions, workflow enginesMulti-step processes

Event Triggers

Serverless functions respond to events — not just HTTP requests.

HTTP Triggers

The most common trigger — your function acts as an API endpoint.

// Vercel: app/api/webhook/route.ts
export async function POST(request: NextRequest) {
  const payload = await request.json();
  await processWebhook(payload);
  return NextResponse.json({ received: true });
}

Queue Triggers

Process messages from a queue asynchronously.

// AWS Lambda: SQS trigger
export async function handler(event: SQSEvent) {
  for (const record of event.Records) {
    const message = JSON.parse(record.body);
    await processOrder(message);
  }
}

Schedule Triggers (Cron)

Run functions on a schedule — perfect for periodic tasks.

// Vercel: app/api/cron/daily-report/route.ts
export async function GET(request: NextRequest) {
  // Verify cron secret
  const authHeader = request.headers.get("authorization");
  if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }
 
  const report = await generateDailyReport();
  await sendReportEmail(report);
  return NextResponse.json({ success: true });
}
// vercel.json
{
  "crons": [
    {
      "path": "/api/cron/daily-report",
      "schedule": "0 9 * * *"
    }
  ]
}

Storage Triggers

React to file uploads or changes in object storage.

// AWS Lambda: S3 trigger — process uploaded images
export async function handler(event: S3Event) {
  for (const record of event.Records) {
    const bucket = record.s3.bucket.name;
    const key = record.s3.object.key;
 
    // Download, resize, and upload thumbnail
    const image = await s3.getObject({ Bucket: bucket, Key: key });
    const thumbnail = await sharp(image.Body).resize(200, 200).toBuffer();
    await s3.putObject({
      Bucket: bucket,
      Key: `thumbnails/${key}`,
      Body: thumbnail,
    });
  }
}

Database Triggers

React to database changes — available in some platforms.

// Supabase: Database webhook on new user
// Triggers when a row is inserted into the "users" table
export async function handler(event: any) {
  const newUser = event.record;
  await sendWelcomeEmail(newUser.email);
  await createDefaultSettings(newUser.id);
}

Serverless Databases

Traditional databases assume long-lived connections from a few servers. Serverless functions create many short-lived connections from many instances, which can overwhelm traditional databases.

The Connection Problem

100 Lambda invocations = 100 database connections. Scale to 1000 invocations and your database runs out of connections.

Solutions

1. Serverless-native databases

Databases designed for serverless workloads handle connection pooling automatically.

// Neon — serverless Postgres with HTTP driver
import { neon } from "@neondatabase/serverless";
 
const sql = neon(process.env.DATABASE_URL!);
 
export async function handler() {
  // HTTP-based query — no persistent connection needed
  const users = await sql`SELECT * FROM users WHERE active = true`;
  return { statusCode: 200, body: JSON.stringify(users) };
}

2. Connection pooling

Use a connection pooler between your functions and the database.

// Neon with connection pooling
// Connection string uses the pooler endpoint
const sql = neon(process.env.DATABASE_URL!);
// DATABASE_URL=postgres://user:pass@ep-xxx-pooler.us-east-2.aws.neon.tech/neondb

3. DynamoDB — truly serverless NoSQL

import { DynamoDBClient, GetItemCommand } from "@aws-sdk/client-dynamodb";
 
const client = new DynamoDBClient({});
 
export async function handler(event: any) {
  const result = await client.send(
    new GetItemCommand({
      TableName: "Users",
      Key: { userId: { S: event.pathParameters.id } },
    })
  );
  return { statusCode: 200, body: JSON.stringify(result.Item) };
}

Serverless Database Comparison

DatabaseTypeConnection ModelBest For
NeonPostgreSQLHTTP + poolingFull SQL, branching, scale-to-zero
PlanetScaleMySQLHTTP driverMySQL workloads, branching
SupabasePostgreSQLPooling + RESTFull-stack with auth, real-time
DynamoDBNoSQLHTTP (native)High-throughput key-value
Upstash RedisKey-valueHTTPCaching, rate limiting, queues
TursoSQLiteHTTPEdge-first, embedded

Composition Patterns

Individual functions are simple. The challenge is composing them into complex workflows.

1. Function Chaining

One function calls another sequentially.

// Simple chaining — each function calls the next
export async function validateOrder(order: Order) {
  validateItems(order.items);
  validateAddress(order.shippingAddress);
 
  // Call next function
  await processPayment(order);
}
 
async function processPayment(order: Order) {
  const payment = await stripe.charges.create({
    amount: order.total,
    currency: "usd",
  });
 
  await reserveInventory(order, payment.id);
}

Problem: If reserveInventory fails, you've already charged the customer. No built-in error handling or retry.

2. Fan-Out / Fan-In

Distribute work across parallel functions, then aggregate results.

// Fan-out: Split work into parallel tasks
export async function processLargeDataset(event: any) {
  const records = await fetchRecords();
  const batches = chunk(records, 100); // Split into batches of 100
 
  // Fan-out: invoke parallel Lambda functions
  const promises = batches.map((batch, index) =>
    lambda.invoke({
      FunctionName: "processBatch",
      Payload: JSON.stringify({ batch, batchIndex: index }),
    })
  );
 
  const results = await Promise.all(promises);
 
  // Fan-in: aggregate results
  const totalProcessed = results.reduce(
    (sum, r) => sum + JSON.parse(r.Payload!).processed,
    0
  );
  return { totalProcessed };
}

3. Orchestration (Step Functions / Durable Functions)

For complex, multi-step workflows with error handling, retries, and human approval.

// AWS Step Functions state machine (simplified)
{
  "StartAt": "ValidateOrder",
  "States": {
    "ValidateOrder": {
      "Type": "Task",
      "Resource": "arn:aws:lambda:us-east-1:123:function:validateOrder",
      "Next": "ProcessPayment",
      "Catch": [{ "ErrorEquals": ["ValidationError"], "Next": "OrderFailed" }]
    },
    "ProcessPayment": {
      "Type": "Task",
      "Resource": "arn:aws:lambda:us-east-1:123:function:processPayment",
      "Retry": [{ "ErrorEquals": ["TimeoutError"], "MaxAttempts": 3 }],
      "Next": "ReserveInventory",
      "Catch": [{ "ErrorEquals": ["PaymentDeclined"], "Next": "OrderFailed" }]
    },
    "ReserveInventory": {
      "Type": "Task",
      "Resource": "arn:aws:lambda:us-east-1:123:function:reserveInventory",
      "Next": "SendConfirmation"
    },
    "SendConfirmation": {
      "Type": "Task",
      "Resource": "arn:aws:lambda:us-east-1:123:function:sendConfirmation",
      "End": true
    },
    "OrderFailed": {
      "Type": "Fail",
      "Cause": "Order processing failed"
    }
  }
}

When to Use Each Pattern

PatternUse WhenAvoid When
ChainingSimple sequential steps, all-or-nothingComplex error handling needed
Fan-out/fan-inParallel independent workSteps depend on each other
OrchestrationComplex workflows, retries, human approval, long-running processesSimple request-response
Event-drivenLoose coupling, eventual consistencyStrict ordering required

Cost Model

Serverless pricing is fundamentally different from traditional hosting.

Pay-Per-Invocation

Traditional server:
  1 EC2 t3.medium = $30/month (running 24/7, even at 2 AM with zero traffic)
 
Serverless (AWS Lambda):
  Price = (invocations × $0.20/million) + (duration × $0.0000166667/GB-second)
 
  Example: 1 million requests/month, 200ms average, 256MB memory
  = (1M × $0.20/M) + (1M × 0.2s × 0.25GB × $0.0000166667)
  = $0.20 + $0.83
  = $1.03/month

When Serverless Is Cheaper

Serverless saves money when:

  • Traffic is spiky or unpredictable
  • Many endpoints with low individual traffic
  • Development/staging environments (scale to zero)
  • Background jobs that run periodically

Servers are cheaper when:

  • Consistent, high traffic (>100M requests/month)
  • Long-running processes (>15 minutes)
  • GPU workloads or heavy computation
  • Predictable, steady load

Hidden Costs

  • Data transfer between services (egress fees)
  • API Gateway charges ($3.50/million requests on AWS)
  • Step Functions state transitions ($25/million transitions)
  • CloudWatch logs and monitoring
  • Provisioned concurrency for eliminating cold starts

Limitations

Execution Time Limits

PlatformMax Execution Time
AWS Lambda15 minutes
Azure Functions10 minutes (Consumption), unlimited (Premium)
Google Cloud Functions9 minutes (1st gen), 60 minutes (2nd gen)
Vercel Serverless10 seconds (Hobby), 300 seconds (Pro)
Cloudflare Workers30 seconds (Bundled), 15 minutes (Unbound)

Memory Limits

PlatformMemory Range
AWS Lambda128MB - 10GB
Azure Functions256MB - 14GB
Vercel Serverless1024MB - 3008MB
Cloudflare Workers128MB

Payload Size Limits

PlatformRequest/Response Size
AWS Lambda6MB (sync), 256KB (async)
Azure Functions100MB
Vercel Serverless4.5MB request body
API Gateway10MB

Vendor Lock-In

Each platform has unique APIs, deployment models, and configuration. Moving from AWS Lambda to Azure Functions requires rewriting infrastructure code.

Mitigation strategies:

  • Keep business logic in pure functions, separate from platform-specific handlers
  • Use abstraction layers (e.g., Serverless Framework)
  • Use standard protocols (HTTP, SQL) instead of proprietary services where possible
  • Containerize functions for portability (AWS Lambda supports container images)

Serverless Frameworks

Frameworks simplify deployment, configuration, and local development.

SST (Serverless Stack)

Modern framework built on top of AWS CDK. Supports Next.js, Remix, Astro, and more.

// sst.config.ts
export default {
  config() {
    return { name: "my-app", region: "us-east-1" };
  },
  stacks(app) {
    app.stack(function API({ stack }) {
      const api = new Api(stack, "api", {
        routes: {
          "GET /users": "packages/functions/src/users.list",
          "POST /users": "packages/functions/src/users.create",
        },
      });
      stack.addOutputs({ ApiEndpoint: api.url });
    });
  },
};

Serverless Framework

The most widely-used framework. Supports AWS, Azure, GCP.

# serverless.yml
service: my-api
provider:
  name: aws
  runtime: nodejs20.x
  region: us-east-1
 
functions:
  getUsers:
    handler: src/handlers/users.getAll
    events:
      - http:
          path: /users
          method: get
 
  processOrder:
    handler: src/handlers/orders.process
    events:
      - sqs:
          arn: !GetAtt OrderQueue.Arn
          batchSize: 10
 
resources:
  Resources:
    OrderQueue:
      Type: AWS::SQS::Queue

AWS SAM (Serverless Application Model)

AWS-native framework using CloudFormation templates.

# template.yaml
AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31
 
Resources:
  GetUsersFunction:
    Type: AWS::Serverless::Function
    Properties:
      Handler: src/handlers/users.getAll
      Runtime: nodejs20.x
      Events:
        GetUsers:
          Type: Api
          Properties:
            Path: /users
            Method: get

Framework Comparison

FeatureSSTServerless FrameworkAWS SAM
Cloud supportAWSAWS, Azure, GCPAWS only
LanguageTypeScriptYAML + pluginsYAML (CloudFormation)
Local devLive Lambda (hot reload)Offline pluginLocal invoke
Full-stackNext.js, Remix, AstroAPI-focusedAPI-focused
Learning curveMediumLowMedium

When Serverless Makes Sense

Great Fit

1. APIs with variable traffic

Monday morning: 10,000 requests/minute
Saturday night: 50 requests/minute
→ Serverless auto-scales and costs almost nothing when idle

2. Event processing pipelines

3. Scheduled tasks and cron jobs

Daily report generation → 1 Lambda invocation per day
Weekly email digest → 1 invocation per week
→ Pay for seconds of compute, not 24/7 server

4. Webhooks and integrations

Functions that wait for third-party callbacks (Stripe, GitHub, Slack).

5. Prototypes and MVPs

Ship fast with zero infrastructure management. Scale later if needed.

Poor Fit

1. Long-running processes

Video transcoding, ML model training, batch ETL jobs that run for hours.

2. WebSocket / persistent connections

Standard FaaS doesn't support long-lived connections (though Cloudflare Workers Durable Objects and AWS API Gateway WebSocket do).

3. Consistent high-throughput

If you process 50,000 requests per second consistently, a container or VM is significantly cheaper.

4. Local state requirements

In-memory caches, session state, or data that must persist between requests.


Hybrid Architectures

Most production systems use a mix of serverless and traditional components.

Common patterns:

  • API layer: Serverless (auto-scaling, pay-per-use)
  • Real-time: Always-on WebSocket server
  • Background processing: Queue + serverless workers (for short tasks) or containers (for long tasks)
  • Database: Serverless-native (Neon, DynamoDB)
  • Caching: Serverless Redis (Upstash)

Summary

Serverless architecture shifts operational responsibility from you to the cloud provider, letting you focus on code instead of infrastructure.

Core concepts:

  • FaaS — deploy individual functions that scale automatically
  • BaaS — use managed services (auth, database, storage) instead of building them
  • Stateless design — functions have no shared memory; use external state stores
  • Event-driven — functions respond to triggers (HTTP, queues, schedules, storage events)

Key benefits:

  • Zero server management (no provisioning, patching, scaling decisions)
  • Pay-per-use pricing (no cost when idle)
  • Automatic scaling from zero to thousands of concurrent invocations
  • Faster time-to-market (focus on business logic, not infrastructure)

Key challenges:

  • Cold starts add latency (mitigate with provisioned concurrency, fast runtimes)
  • Execution time limits (15 min max on Lambda)
  • Vendor lock-in (keep business logic portable)
  • Debugging and observability are harder (distributed tracing, structured logging)
  • Connection management with traditional databases (use serverless databases)

When to use:

  • Variable or unpredictable traffic
  • Event processing, webhooks, scheduled tasks
  • APIs where per-endpoint scaling makes sense
  • Prototypes, MVPs, and early-stage products

When to avoid:

  • Consistent high-throughput workloads (cheaper with servers)
  • Long-running processes (>15 minutes)
  • WebSocket or persistent connection requirements
  • Heavy compute (ML training, video processing)

Serverless isn't about eliminating servers — it's about eliminating the decisions about servers. Let the platform handle capacity, scaling, and availability while you build what matters.


What's Next in the Software Architecture Series

This is post 11 of 12 in the Software Architecture Patterns series:

Related posts:

📬 Subscribe to Newsletter

Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.

We respect your privacy. Unsubscribe at any time.

💬 Comments

Sign in to leave a comment

We'll never post without your permission.