Loading learning content...
Building HTTP APIs is arguably the most common use case for serverless functions. The combination of managed API gateways and auto-scaling compute creates an exceptionally powerful platform for exposing services to web applications, mobile apps, third-party integrations, and internal microservices.
Traditional API servers require you to provision instances, configure load balancers, manage SSL certificates, and handle capacity planning. With serverless APIs, much of this complexity shifts to the cloud provider. You write request handlers; the platform handles scaling, availability, and security infrastructure.
This page provides a comprehensive guide to building serverless API backends. We'll cover the full lifecycle from request routing through response delivery, examining architectural patterns, authentication strategies, performance optimization, and operational best practices that distinguish production-grade APIs from fragile prototypes.
By the end of this page, you will understand: (1) How API Gateways orchestrate serverless API requests, (2) Patterns for structuring serverless API handlers, (3) Authentication and authorization strategies for serverless APIs, (4) Request validation and response formatting best practices, (5) Performance optimization techniques including connection reuse and response caching, and (6) Architectural patterns for organizing larger serverless API surfaces.
The API Gateway is the front door to your serverless API. It receives HTTP requests, routes them to appropriate Lambda functions (or other backends), and returns responses to clients. Understanding API Gateway capabilities is essential for building effective serverless APIs.
Key API Gateway Responsibilities:
| Provider | Service | Key Features | Pricing Model |
|---|---|---|---|
| AWS | API Gateway (REST) | Full feature set, WebSocket support, caching | Per request + data transfer |
| AWS | API Gateway (HTTP) | Lower cost, simpler, Lambda-optimized | Per request, 70% cheaper than REST |
| Azure | API Management | Policies, developer portal, versioning | Consumption or fixed tier |
| GCP | Cloud Endpoints | OpenAPI spec, authentication, monitoring | Per call pricing |
| GCP | API Gateway | Managed, serverless, gRPC support | Per call + data |
| Cloudflare | Workers + Routes | Edge-native, low latency, global | Per request pricing |
AWS API Gateway Types:
AWS offers two main API Gateway types, each with different trade-offs:
REST API (v1):
HTTP API (v2):
For most new serverless APIs, start with HTTP API. It's significantly cheaper, lower latency, and simpler to configure. Only use REST API if you specifically need its advanced features like request transformation, caching, or AWS WAF integration. You can always migrate later if needed.
How you structure your Lambda handlers significantly impacts development velocity, maintainability, and performance. There are two primary approaches: single-purpose functions and monolithic routers.
Single-Purpose Functions:
Each Lambda function handles one route or a small group of related routes. This approach aligns with microservices principles:
Advantages:
Disadvantages:
12345678910111213141516171819202122232425262728293031
// functions/users/create.tsimport { APIGatewayProxyHandlerV2 } from "aws-lambda";import { createUser } from "../services/user-service";import { validateRequest } from "../middleware/validation";import { CreateUserSchema } from "../schemas/user"; export const handler: APIGatewayProxyHandlerV2 = async (event) => { try { // Parse and validate request body const body = JSON.parse(event.body || "{}"); const validatedData = validateRequest(CreateUserSchema, body); // Execute business logic const user = await createUser(validatedData); // Return success response return { statusCode: 201, headers: { "Content-Type": "application/json", "Cache-Control": "no-cache" }, body: JSON.stringify({ success: true, data: user }) }; } catch (error) { return handleError(error); }};Monolithic Router (Framework Approach):
A single Lambda function handles all routes, using an internal router (Express, Fastify, or similar). This approach is familiar to developers from traditional server development:
Advantages:
Disadvantages:
1234567891011121314151617181920212223242526272829303132
// handler.tsimport express from "express";import serverless from "serverless-http";import cors from "cors";import { authMiddleware } from "./middleware/auth";import { errorHandler } from "./middleware/error";import { usersRouter } from "./routes/users";import { ordersRouter } from "./routes/orders";import { productsRouter } from "./routes/products"; const app = express(); // Global middlewareapp.use(cors());app.use(express.json());app.use(authMiddleware); // Route handlersapp.use("/api/users", usersRouter);app.use("/api/orders", ordersRouter);app.use("/api/products", productsRouter); // Health check (no auth required)app.get("/health", (req, res) => { res.json({ status: "healthy", timestamp: new Date().toISOString() });}); // Error handlingapp.use(errorHandler); // Export for Lambdaexport const handler = serverless(app);Hybrid Approach:
Many production systems use a hybrid: group related endpoints into function "clusters" that share context while keeping unrelated domains separate. For example:
user-api function handles /users/* routesorder-api function handles /orders/* routesadmin-api function handles /admin/* routes with different authThis balances the benefits of both approaches—domain-level isolation with intra-domain code sharing.
| Factor | Single-Purpose | Monolithic | Hybrid |
|---|---|---|---|
| Cold start | Faster (smaller bundles) | Slower (larger bundle) | Medium |
| Scaling | Per-route independent | All routes together | Per-domain |
| Development | More files, clear boundaries | Familiar, single project | Balanced |
| Deployment | Deploy individual functions | Single deployment | Per-domain deploys |
| Best for | High-scale, diverse APIs | Small APIs, rapid prototyping | Most production APIs |
For most teams, the hybrid approach offers the best balance. Start with a monolithic router for rapid development, then extract high-traffic or distinctly different endpoints into separate functions as the API matures and scaling requirements become clear.
Securing serverless APIs requires robust authentication (verifying identity) and authorization (verifying permissions). API Gateway provides several mechanisms, each with different trade-offs.
JWT Authorization (HTTP API):
HTTP API's native JWT authorizer validates JSON Web Tokens without invoking Lambda:
12345678910111213141516171819202122232425262728293031323334
import * as apigatewayv2 from "aws-cdk-lib/aws-apigatewayv2";import * as authorizers from "aws-cdk-lib/aws-apigatewayv2-authorizers"; // Create JWT authorizer for HTTP APIconst jwtAuthorizer = new authorizers.HttpJwtAuthorizer( "JwtAuthorizer", "https://your-auth-domain.auth0.com/", { jwtAudience: ["your-api-identifier"], identitySource: ["$request.header.Authorization"] }); // Attach to routesconst httpApi = new apigatewayv2.HttpApi(this, "HttpApi", { defaultAuthorizer: jwtAuthorizer, // Some routes can override or skip auth}); // Protected routehttpApi.addRoutes({ path: "/api/protected", methods: [apigatewayv2.HttpMethod.GET], integration: protectedIntegration, authorizer: jwtAuthorizer}); // Public route (no auth)httpApi.addRoutes({ path: "/api/public", methods: [apigatewayv2.HttpMethod.GET], integration: publicIntegration, authorizer: new authorizers.HttpNoneAuthorizer()});Lambda Authorizers:
For complex authorization logic that can't be expressed as JWT validation, Lambda authorizers execute custom code:
Token-Based Authorizer: Receives the authorization token, validates it, and returns an IAM policy specifying allowed/denied resources.
Request-Based Authorizer: Receives the entire request context (headers, query params, path) for more sophisticated authorization decisions.
Lambda authorizers add latency and cost (each invocation is billed), but enable scenarios like:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152
import { APIGatewayTokenAuthorizerHandler, APIGatewayAuthorizerResult } from "aws-lambda";import { verify, JwtPayload } from "jsonwebtoken";import { getUserPermissions } from "./services/permissions"; const JWT_SECRET = process.env.JWT_SECRET!; export const handler: APIGatewayTokenAuthorizerHandler = async (event) => { try { const token = event.authorizationToken.replace("Bearer ", ""); // Verify JWT signature and expiration const decoded = verify(token, JWT_SECRET) as JwtPayload; // Fetch user's permissions from database const permissions = await getUserPermissions(decoded.sub!); // Generate IAM policy based on permissions return generatePolicy( decoded.sub!, "Allow", event.methodArn, { userId: decoded.sub, email: decoded.email, permissions: JSON.stringify(permissions) } ); } catch (error) { console.error("Authorization failed:", error); throw new Error("Unauthorized"); }}; function generatePolicy( principalId: string, effect: "Allow" | "Deny", resource: string, context?: Record<string, string>): APIGatewayAuthorizerResult { return { principalId, policyDocument: { Version: "2012-10-17", Statement: [{ Action: "execute-api:Invoke", Effect: effect, Resource: resource }] }, context };}API Keys:
For service-to-service authentication or third-party integrations, API keys provide a simple mechanism:
x-api-key headerCognito Integration (AWS):
Amazon Cognito integrates directly with API Gateway for user authentication:
Lambda authorizers can cache results to reduce latency and cost. However, cached authorization may not reflect real-time permission changes. Set appropriate TTL (often 5-15 minutes) based on your security requirements. For highly sensitive operations, consider disabling caching or using shorter TTLs.
Robust input validation and consistent response formatting are hallmarks of production-quality APIs. They prevent security vulnerabilities, improve developer experience, and make debugging easier.
Request Validation:
Validation should occur at multiple levels:
API Gateway can validate against JSON Schema before invoking your function, rejecting malformed requests early:
1234567891011121314151617181920212223242526272829303132
openapi: "3.0.1"paths: /users: post: requestBody: required: true content: application/json: schema: type: object required: - email - name properties: email: type: string format: email maxLength: 255 name: type: string minLength: 1 maxLength: 100 age: type: integer minimum: 0 maximum: 150 x-amazon-apigateway-request-validator: all x-amazon-apigateway-request-validators: all: validateRequestBody: true validateRequestParameters: trueLambda-Level Validation with Zod:
For complex validation logic, use a schema validation library like Zod in your Lambda handler. This enables type-safe parsing and detailed error messages:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748
import { z } from "zod";import { APIGatewayProxyHandlerV2 } from "aws-lambda"; // Define schema with detailed validationconst CreateUserSchema = z.object({ email: z.string() .email("Invalid email format") .max(255, "Email too long"), name: z.string() .min(1, "Name is required") .max(100, "Name too long") .regex(/^[a-zA-Zs-]+$/, "Name contains invalid characters"), age: z.number() .int("Age must be an integer") .min(0, "Age cannot be negative") .max(150, "Invalid age") .optional(), preferences: z.object({ newsletter: z.boolean().default(false), theme: z.enum(["light", "dark", "system"]).default("system") }).optional()}); type CreateUserInput = z.infer<typeof CreateUserSchema>; export const handler: APIGatewayProxyHandlerV2 = async (event) => { try { // Parse request body const body = JSON.parse(event.body || "{}"); // Validate with Zod - throws ZodError on failure const validatedData: CreateUserInput = CreateUserSchema.parse(body); // Safe to use validatedData - fully typed and validated const user = await createUser(validatedData); return formatSuccess(201, user); } catch (error) { if (error instanceof z.ZodError) { // Format validation errors for client return formatError(400, "Validation failed", error.errors.map(e => ({ field: e.path.join("."), message: e.message }))); } throw error; }};Consistent Response Formatting:
Define a standard response envelope that all endpoints use. This consistency simplifies client-side handling and debugging:
Success Response:
{
"success": true,
"data": { ... },
"meta": {
"requestId": "abc-123",
"timestamp": "2024-01-15T14:32:16Z"
}
}
Error Response:
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "Request validation failed",
"details": [
{ "field": "email", "message": "Invalid email format" }
]
},
"meta": {
"requestId": "abc-123",
"timestamp": "2024-01-15T14:32:16Z"
}
}
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374
import { APIGatewayProxyResultV2 } from "aws-lambda";import { randomUUID } from "crypto"; interface ResponseMeta { requestId: string; timestamp: string;} function getMeta(requestId?: string): ResponseMeta { return { requestId: requestId || randomUUID(), timestamp: new Date().toISOString() };} export function formatSuccess<T>( statusCode: number, data: T, requestId?: string): APIGatewayProxyResultV2 { return { statusCode, headers: { "Content-Type": "application/json", "Cache-Control": "no-cache" }, body: JSON.stringify({ success: true, data, meta: getMeta(requestId) }) };} export function formatError( statusCode: number, code: string, message: string, details?: unknown, requestId?: string): APIGatewayProxyResultV2 { return { statusCode, headers: { "Content-Type": "application/json", "Cache-Control": "no-cache" }, body: JSON.stringify({ success: false, error: { code, message, ...(details && { details }) }, meta: getMeta(requestId) }) };} // Pre-configured error responsesexport const errors = { badRequest: (message: string, details?: unknown) => formatError(400, "BAD_REQUEST", message, details), unauthorized: () => formatError(401, "UNAUTHORIZED", "Authentication required"), forbidden: () => formatError(403, "FORBIDDEN", "Insufficient permissions"), notFound: (resource: string) => formatError(404, "NOT_FOUND", `${resource} not found`), conflict: (message: string) => formatError(409, "CONFLICT", message), internal: (requestId?: string) => formatError(500, "INTERNAL_ERROR", "An unexpected error occurred", undefined, requestId)};Always include a request ID in responses and logs. When users report issues, they can provide the request ID, enabling you to trace the request through your entire system. API Gateway provides a built-in request ID in the event context that you should propagate.
Serverless APIs face unique performance challenges, particularly cold starts. However, with proper optimization, serverless APIs can achieve latencies competitive with traditional servers.
Cold Start Mitigation:
Cold starts occur when a new Lambda container must be initialized. Strategies to minimize their impact:
123456789101112131415161718192021222324252627282930
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb";import { APIGatewayProxyHandlerV2 } from "aws-lambda"; // ✓ Initialize OUTSIDE handler - reused across invocationsconst client = new DynamoDBClient({ // Configure connection reuse maxAttempts: 3});const docClient = DynamoDBDocumentClient.from(client); // ✓ Pre-parse environment variablesconst TABLE_NAME = process.env.TABLE_NAME!;const REGION = process.env.AWS_REGION!; export const handler: APIGatewayProxyHandlerV2 = async (event) => { // ✗ DON'T initialize clients inside handler // const client = new DynamoDBClient({}); // WRONG - creates new client every request // ✓ Use pre-initialized clients const result = await docClient.get({ TableName: TABLE_NAME, Key: { id: event.pathParameters?.id } }); return { statusCode: 200, body: JSON.stringify(result.Item) };};Connection Reuse:
Database connections are expensive to establish. In serverless, connections persist across invocations in the same container, so proper connection handling is critical:
pg (PostgreSQL) or MongoDB driver manage pools| Runtime | Typical Cold Start | Optimization Potential |
|---|---|---|
| Node.js 18.x | 200-400ms | Reduce bundle size, lazy imports |
| Python 3.11 | 250-500ms | Minimize imports, use slim layers |
| Java 17 (SnapStart) | 100-200ms | Enable SnapStart for consistent starts |
| Go | 50-100ms | Already optimized, single binary |
| Rust | 10-50ms | Fastest possible cold starts |
| .NET 6 | 300-600ms | Native AOT compilation helps |
API Gateway Caching:
For read-heavy endpoints with cacheable responses, API Gateway caching (REST API only) can dramatically reduce latency and Lambda invocations:
Response Compression:
Enable response compression for JSON payloads:
Accept-Encoding: gzipProvisioned Concurrency eliminates cold starts by keeping containers warm, but you pay for the provisioned capacity whether or not it's used. Use it for latency-sensitive endpoints (user-facing APIs, real-time features) but not for background processing or low-traffic endpoints where cold starts are acceptable.
As serverless APIs grow in complexity, proper architectural organization becomes essential. These patterns help manage complexity while maintaining serverless benefits.
Domain-Driven Service Boundaries:
Organize functions around business domains rather than technical layers:
/functions
/users
- create.ts
- get.ts
- update.ts
/orders
- create.ts
- list.ts
- fulfill.ts
/inventory
- check.ts
- reserve.ts
Each domain can have its own:
Backend for Frontend (BFF) Pattern:
Different clients often need different API shapes. The BFF pattern creates specialized API layers for each client type:
Each BFF aggregates and transforms data from underlying domain services.
API Composition:
For complex operations that span multiple domains, an API composition layer orchestrates calls to domain services and combines results. This avoids client-side orchestration complexity:
For new APIs, starting with a simpler monolithic handler and extracting functions as domains emerge often works better than premature optimization. You'll learn the true domain boundaries from real usage patterns, not upfront speculation.
Production serverless APIs require robust operational practices—monitoring, logging, alerting, and incident response capabilities.
Structured Logging:
Log in JSON format with consistent fields across all functions:
{
"level": "INFO",
"requestId": "abc-123",
"path": "/api/users",
"method": "POST",
"statusCode": 201,
"duration": 45,
"userId": "user-456",
"message": "User created successfully"
}
Use CloudWatch Logs Insights, Datadog, or similar tools to query across functions.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657
import { APIGatewayProxyHandlerV2, APIGatewayProxyResultV2 } from "aws-lambda"; type Handler = APIGatewayProxyHandlerV2; export function withLogging(handler: Handler): Handler { return async (event, context) => { const startTime = Date.now(); const requestId = event.requestContext.requestId; // Log request console.log(JSON.stringify({ level: "INFO", type: "request", requestId, path: event.rawPath, method: event.requestContext.http.method, userAgent: event.headers["user-agent"], sourceIp: event.requestContext.http.sourceIp })); let response: APIGatewayProxyResultV2; try { response = await handler(event, context); } catch (error) { // Log error console.error(JSON.stringify({ level: "ERROR", type: "unhandled_error", requestId, error: { name: (error as Error).name, message: (error as Error).message, stack: (error as Error).stack } })); throw error; } // Log response const statusCode = typeof response === "object" ? response.statusCode : 200; console.log(JSON.stringify({ level: statusCode >= 400 ? "WARN" : "INFO", type: "response", requestId, statusCode, duration: Date.now() - startTime })); return response; };} // Usageexport const handler = withLogging(async (event) => { // Your handler logic return { statusCode: 200, body: "OK" };});Key Metrics to Monitor:
Alerting Strategy:
| Metric | Namespace | What It Tells You |
|---|---|---|
| Invocations | AWS/Lambda | Total function executions |
| Errors | AWS/Lambda | Unhandled exceptions |
| Duration | AWS/Lambda | Execution time in ms |
| Throttles | AWS/Lambda | Requests rejected due to limits |
| ConcurrentExecutions | AWS/Lambda | Simultaneous executions |
| Count | AWS/ApiGateway | Total API requests |
| 4XXError | AWS/ApiGateway | Client error responses |
| 5XXError | AWS/ApiGateway | Server error responses |
| Latency | AWS/ApiGateway | End-to-end request latency |
| IntegrationLatency | AWS/ApiGateway | Time in backend (Lambda) |
For APIs that call downstream services, enable AWS X-Ray or equivalent distributed tracing. Without it, debugging latency issues or errors that span multiple functions becomes nearly impossible. The small overhead is worth the operational visibility it provides.
Serverless APIs combine the power of managed API gateways with auto-scaling compute to create remarkably efficient backend systems. By understanding the patterns and practices we've covered, you can build APIs that are secure, performant, and operationally sound.
Let's consolidate the key takeaways:
What's Next:
With a solid understanding of API backends, we'll explore another powerful serverless pattern: Scheduled Tasks. You'll learn how to implement cron-like batch processing, time-triggered workflows, and periodic maintenance tasks using serverless schedulers.
You now have comprehensive knowledge of building serverless API backends. From API Gateway configuration through request handling, authentication, validation, performance optimization, and operations—these patterns enable you to build APIs that scale automatically, cost nothing when idle, and handle traffic spikes gracefully.