Building Serverless Applications
Follow these patterns for serverless architecture. Covers cold starts, connection pooling, function composition, and cost optimization.
🔌 MCP Servers (Cloudflare)
If using Cloudflare Workers, they provide official MCP servers:
Where to put this config (Speck template repos):
- •Add the snippet to
.cursor/mcp.project.json.example(create if missing; committed, no secrets) - •Then run:
bash .speck/scripts/bash/merge-mcp-config.shto generate.cursor/mcp.json(local)
{
"mcpServers": {
"cloudflare-workers": {
"type": "http",
"url": "https://bindings.mcp.cloudflare.com/mcp"
},
"cloudflare-observability": {
"type": "http",
"url": "https://observability.mcp.cloudflare.com/mcp"
}
}
}
Available servers: Workers Bindings, Builds, Observability, Containers, Browser Rendering, Logpush, AI Gateway, and more. See Cloudflare MCP docs.
When This Rule Applies
Apply when building with AWS Lambda, Vercel Functions, Cloudflare Workers, or similar FaaS platforms.
Cold Start Mitigation
Keep Functions Warm
// Provisioned concurrency (AWS Lambda)
// Configure in serverless.yml or AWS Console
// Or use scheduled warming
// CloudWatch rule: rate(5 minutes)
export const warmer = async (event) => {
if (event.source === 'serverless-plugin-warmup') {
console.log('Warming up');
return 'warmed';
}
// ... normal handler
};
Minimize Package Size
// Use tree-shakeable imports
import { DynamoDB } from '@aws-sdk/client-dynamodb'; // ✓
// NOT: import AWS from 'aws-sdk'; // ✗
// Bundle with esbuild/rollup
// Target: node18, external: ['@aws-sdk/*']
Initialize Outside Handler
// ✓ GOOD: Initialized once per container
const db = new DynamoDB();
const stripe = new Stripe(process.env.STRIPE_KEY);
export const handler = async (event) => {
// db and stripe already initialized
return await db.query(...);
};
// ✗ BAD: Initialized on every invocation
export const handler = async (event) => {
const db = new DynamoDB(); // Cold start every time
return await db.query(...);
};
Database Connection Pooling
The Problem
Serverless functions can spawn hundreds of concurrent instances, each opening its own database connection → connection exhaustion.
Solutions
| Solution | Platform | Use Case |
|---|---|---|
| Connection pooler | Any | PostgreSQL (PgBouncer, Supabase) |
| HTTP-based DB | Any | Planetscale, Neon, Supabase |
| Serverless adapter | Prisma | @prisma/adapter-neon |
| Data API | AWS | Aurora Serverless Data API |
Prisma with Connection Pooling
import { PrismaClient } from '@prisma/client';
import { neonConfig, Pool } from '@neondatabase/serverless';
import { PrismaNeon } from '@prisma/adapter-neon';
neonConfig.fetchConnectionCache = true;
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const adapter = new PrismaNeon(pool);
const prisma = new PrismaClient({ adapter });
export const handler = async (event) => {
const users = await prisma.user.findMany();
return { statusCode: 200, body: JSON.stringify(users) };
};
Supabase with Pooler
// Use pooler URL instead of direct connection
const supabase = createClient(
process.env.SUPABASE_URL,
process.env.SUPABASE_KEY,
{
db: {
// Use transaction pooler for serverless
schema: 'public',
},
}
);
Function Patterns
Single Responsibility
// ✓ GOOD: One function per operation
// functions/createUser.ts
// functions/getUser.ts
// functions/updateUser.ts
// ✗ BAD: Monolithic handler
export const handler = async (event) => {
switch (event.httpMethod) {
case 'GET': return getUser(event);
case 'POST': return createUser(event);
case 'PUT': return updateUser(event);
// ... becomes unmaintainable
}
};
API Gateway Pattern
// Vercel: app/api/users/[id]/route.ts
export async function GET(
request: Request,
{ params }: { params: { id: string } }
) {
const user = await db.user.findUnique({ where: { id: params.id } });
return Response.json(user);
}
export async function PUT(
request: Request,
{ params }: { params: { id: string } }
) {
const data = await request.json();
const user = await db.user.update({ where: { id: params.id }, data });
return Response.json(user);
}
Async Processing
Queue-Based (AWS)
// Producer: API function
export const createOrder = async (event) => {
const order = await db.order.create({ data: event.body });
await sqs.sendMessage({
QueueUrl: process.env.ORDERS_QUEUE_URL,
MessageBody: JSON.stringify({ orderId: order.id }),
});
return { statusCode: 202, body: 'Processing' };
};
// Consumer: Queue-triggered function
export const processOrder = async (event) => {
for (const record of event.Records) {
const { orderId } = JSON.parse(record.body);
await fulfillOrder(orderId);
}
};
Background Jobs (Vercel)
// Use waitUntil for fire-and-forget
export async function POST(request: Request) {
const data = await request.json();
// Respond immediately
const response = Response.json({ status: 'accepted' });
// Continue processing after response
waitUntil(
sendWelcomeEmail(data.email)
.then(() => updateAnalytics(data))
);
return response;
}
Environment Configuration
Per-Stage Config
# serverless.yml
provider:
environment:
NODE_ENV: ${opt:stage, 'dev'}
custom:
database:
dev: ${ssm:/app/dev/database-url}
prod: ${ssm:/app/prod/database-url}
functions:
api:
environment:
DATABASE_URL: ${self:custom.database.${opt:stage, 'dev'}}
Secrets Management
// AWS Secrets Manager
import { SecretsManager } from '@aws-sdk/client-secrets-manager';
const sm = new SecretsManager();
let cachedSecret: string;
async function getSecret(name: string): Promise<string> {
if (cachedSecret) return cachedSecret;
const response = await sm.getSecretValue({ SecretId: name });
cachedSecret = response.SecretString!;
return cachedSecret;
}
Cost Optimization
Memory/Duration Tradeoff
Higher memory = more CPU = faster execution = potentially cheaper.
# Test different memory settings
functions:
api:
memorySize: 1024 # 1GB - often sweet spot
timeout: 10
Avoid Unnecessary Invocations
// Use API Gateway caching
// Configure in AWS Console or serverless.yml
// Or implement at code level
const cache = new Map();
export const handler = async (event) => {
const cacheKey = event.pathParameters.id;
if (cache.has(cacheKey)) {
return { statusCode: 200, body: cache.get(cacheKey) };
}
const data = await fetchData(cacheKey);
cache.set(cacheKey, JSON.stringify(data));
return { statusCode: 200, body: JSON.stringify(data) };
};
Common Gotchas
Timeout Mismatches
API Gateway: 29s max, Lambda: 15 min max. Set Lambda timeout < API Gateway timeout.
Cold Start Spikes
Schedule warmers, use provisioned concurrency for latency-critical functions.
Connection Leaks
Always use connection pooling or HTTP-based databases.
Large Payloads
Lambda payload limit: 6MB. Use S3 for large files:
// Generate presigned URL for upload
const uploadUrl = await s3.getSignedUrl('putObject', {
Bucket: 'uploads',
Key: `uploads/${uuid()}`,
Expires: 300,
});
return { uploadUrl };
Execution Context Reuse
Don't rely on global state persisting, but do reuse expensive initializations.
Quick Reference
| Task | Pattern |
|---|---|
| Reduce cold start | Initialize outside handler |
| Connection pooling | PgBouncer / Neon / Data API |
| Background work | SQS queue or waitUntil |
| Secrets | AWS Secrets Manager / SSM |
| Cost optimize | Test memory settings, use caching |
| Large payloads | S3 presigned URLs |