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:
- Provisioning a container/micro-VM
- Downloading your code package
- Initializing the runtime (Node.js, Python, Java, etc.)
- Running your initialization code (database connections, config loading)
Cold Start Comparison
| Runtime | Cold Start (typical) | Notes |
|---|---|---|
| Node.js | 100-500ms | Fast, small runtime |
| Python | 100-500ms | Fast, similar to Node.js |
| Go | 50-200ms | Compiled binary, fastest |
| Rust | 50-200ms | Compiled binary, very fast |
| Java | 1-10s | JVM startup, class loading |
| .NET | 500ms-3s | CLR 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 start2. Use provisioned concurrency (AWS Lambda)
# serverless.yml
functions:
api:
handler: handler.main
provisionedConcurrency: 5 # 5 instances always warm3. 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 Type | Solution | Example |
|---|---|---|
| Session data | Redis (Upstash), JWT tokens | User login sessions |
| Persistent data | Database (Neon, DynamoDB) | User profiles, orders |
| Cache | Redis (Upstash), CDN | API responses, computed values |
| File uploads | Object storage (S3, R2) | Images, documents |
| Temporary state | Step Functions, workflow engines | Multi-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/neondb3. 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
| Database | Type | Connection Model | Best For |
|---|---|---|---|
| Neon | PostgreSQL | HTTP + pooling | Full SQL, branching, scale-to-zero |
| PlanetScale | MySQL | HTTP driver | MySQL workloads, branching |
| Supabase | PostgreSQL | Pooling + REST | Full-stack with auth, real-time |
| DynamoDB | NoSQL | HTTP (native) | High-throughput key-value |
| Upstash Redis | Key-value | HTTP | Caching, rate limiting, queues |
| Turso | SQLite | HTTP | Edge-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
| Pattern | Use When | Avoid When |
|---|---|---|
| Chaining | Simple sequential steps, all-or-nothing | Complex error handling needed |
| Fan-out/fan-in | Parallel independent work | Steps depend on each other |
| Orchestration | Complex workflows, retries, human approval, long-running processes | Simple request-response |
| Event-driven | Loose coupling, eventual consistency | Strict 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/monthWhen 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
| Platform | Max Execution Time |
|---|---|
| AWS Lambda | 15 minutes |
| Azure Functions | 10 minutes (Consumption), unlimited (Premium) |
| Google Cloud Functions | 9 minutes (1st gen), 60 minutes (2nd gen) |
| Vercel Serverless | 10 seconds (Hobby), 300 seconds (Pro) |
| Cloudflare Workers | 30 seconds (Bundled), 15 minutes (Unbound) |
Memory Limits
| Platform | Memory Range |
|---|---|
| AWS Lambda | 128MB - 10GB |
| Azure Functions | 256MB - 14GB |
| Vercel Serverless | 1024MB - 3008MB |
| Cloudflare Workers | 128MB |
Payload Size Limits
| Platform | Request/Response Size |
|---|---|
| AWS Lambda | 6MB (sync), 256KB (async) |
| Azure Functions | 100MB |
| Vercel Serverless | 4.5MB request body |
| API Gateway | 10MB |
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::QueueAWS 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: getFramework Comparison
| Feature | SST | Serverless Framework | AWS SAM |
|---|---|---|---|
| Cloud support | AWS | AWS, Azure, GCP | AWS only |
| Language | TypeScript | YAML + plugins | YAML (CloudFormation) |
| Local dev | Live Lambda (hot reload) | Offline plugin | Local invoke |
| Full-stack | Next.js, Remix, Astro | API-focused | API-focused |
| Learning curve | Medium | Low | Medium |
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 idle2. 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 server4. 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:
- ✅ ARCH-1: Software Architecture Patterns Roadmap
- ✅ ARCH-2: Monolithic Architecture
- ✅ ARCH-3: Layered (N-Tier) Architecture
- ✅ ARCH-4: MVC, MVP & MVVM Patterns
- ✅ ARCH-5: Microservices Architecture
- ✅ ARCH-6: Event-Driven Architecture
- ✅ ARCH-7: CQRS & Event Sourcing
- ✅ ARCH-8: Hexagonal Architecture
- ✅ ARCH-9: Clean Architecture
- ✅ ARCH-10: Domain-Driven Design (DDD)
- ✅ ARCH-11: Serverless & FaaS Architecture (this post)
- 🔜 ARCH-12: Choosing the Right Architecture
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.