Serverless API Design Best Practices
Design production-ready serverless APIs with validation, error handling, pagination, and authentication patterns for Node.js
Serverless API Design Best Practices
Serverless APIs strip away the infrastructure management overhead but introduce their own set of architectural constraints that demand deliberate design. Getting the fundamentals right—validation, error handling, pagination, auth—determines whether your serverless API becomes a reliable production system or an unpredictable mess. This article covers the patterns and practices I have relied on after building dozens of serverless APIs with AWS Lambda and API Gateway.
Prerequisites
- Node.js 18+ installed locally
- Basic understanding of REST API design principles
- AWS account with access to Lambda and API Gateway
- Familiarity with the Serverless Framework or AWS SAM
- Working knowledge of Express.js or similar HTTP frameworks
API Design Principles for Serverless
Serverless changes the rules. You are not running a persistent server, so the design patterns that work in Express or Fastify need adaptation. Here are the principles that matter most.
One function per route, or one function for all routes. There are two schools of thought. The monolithic approach bundles all routes into a single Lambda function behind API Gateway. The micro approach gives each endpoint its own function. I lean toward a hybrid: group related endpoints into a single function (all /users/* routes in one function, all /orders/* in another). This keeps cold starts manageable while maintaining reasonable deployment sizes.
Stateless by default. Every request must carry everything the function needs to process it. No in-memory sessions, no local file caches that persist between invocations. If you need state, push it to DynamoDB, ElastiCache, or S3.
Design for cold starts. Move expensive initialization outside the handler. Database connections, SDK clients, and configuration loading should happen at module scope so they persist across warm invocations.
// Module-scope initialization - survives across warm invocations
var AWS = require("aws-sdk");
var dynamodb = new AWS.DynamoDB.DocumentClient();
var jwt = require("jsonwebtoken");
// Load config once
var config = {
tableName: process.env.TABLE_NAME,
jwtSecret: process.env.JWT_SECRET,
region: process.env.AWS_REGION || "us-east-1"
};
// Handler runs on every invocation
module.exports.handler = function(event, context) {
// Prevent Lambda from waiting for event loop to drain
context.callbackWaitsForEmptyEventLoop = false;
// Route handling here
};
Keep functions focused. A Lambda function that does one thing well is easier to test, debug, and scale than one that tries to handle fifteen different concerns. If your handler file exceeds 300 lines, it is time to refactor.
Request Validation and Input Sanitization
Never trust input from API Gateway. Even with request validators configured at the gateway level, your Lambda function should validate everything it touches. I use a lightweight validation approach that avoids pulling in massive libraries.
var validator = {
isEmail: function(value) {
var emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return typeof value === "string" && emailRegex.test(value);
},
isUUID: function(value) {
var uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
return typeof value === "string" && uuidRegex.test(value);
},
isPositiveInt: function(value) {
var num = parseInt(value, 10);
return !isNaN(num) && num > 0 && String(num) === String(value);
},
sanitizeString: function(value, maxLength) {
if (typeof value !== "string") return "";
return value
.replace(/[<>]/g, "")
.replace(/&(?!amp;)/g, "&")
.trim()
.substring(0, maxLength || 500);
}
};
function validateCreateUser(body) {
var errors = [];
if (!body.email || !validator.isEmail(body.email)) {
errors.push({ field: "email", message: "Valid email address is required" });
}
if (!body.name || body.name.trim().length < 2) {
errors.push({ field: "name", message: "Name must be at least 2 characters" });
}
if (body.name && body.name.length > 100) {
errors.push({ field: "name", message: "Name must not exceed 100 characters" });
}
return {
valid: errors.length === 0,
errors: errors,
sanitized: {
email: body.email ? body.email.toLowerCase().trim() : "",
name: body.name ? validator.sanitizeString(body.name, 100) : ""
}
};
}
For more complex validation needs, joi or ajv are solid choices. AJV is particularly good for serverless because it compiles JSON Schema validators ahead of time, which means fast validation with minimal overhead on each invocation.
var Ajv = require("ajv");
var ajv = new Ajv({ allErrors: true, coerceTypes: true });
var createUserSchema = {
type: "object",
required: ["email", "name"],
properties: {
email: { type: "string", format: "email", maxLength: 254 },
name: { type: "string", minLength: 2, maxLength: 100 },
role: { type: "string", enum: ["user", "admin", "editor"] }
},
additionalProperties: false
};
// Compile once at module scope
var validateUser = ajv.compile(createUserSchema);
Response Formatting and Status Codes
Consistent response formatting is non-negotiable. Every response from your API should follow the same structure regardless of success or failure. Here is the pattern I use across all serverless APIs.
function buildResponse(statusCode, body, headers) {
var defaultHeaders = {
"Content-Type": "application/json",
"X-Request-Id": body.requestId || "unknown",
"Cache-Control": "no-cache"
};
var mergedHeaders = Object.assign({}, defaultHeaders, headers || {});
return {
statusCode: statusCode,
headers: mergedHeaders,
body: JSON.stringify(body)
};
}
function successResponse(data, meta) {
var body = { success: true, data: data };
if (meta) body.meta = meta;
return buildResponse(200, body);
}
function createdResponse(data) {
return buildResponse(201, { success: true, data: data });
}
function errorResponse(statusCode, message, details) {
var body = {
success: false,
error: {
message: message,
details: details || null
}
};
return buildResponse(statusCode, body);
}
function validationErrorResponse(errors) {
return buildResponse(400, {
success: false,
error: {
message: "Validation failed",
details: errors
}
});
}
Use HTTP status codes correctly. I see too many serverless APIs returning 200 for everything with an error flag in the body. That breaks HTTP semantics and makes integration harder for consumers.
| Status Code | When to Use |
|---|---|
| 200 | Successful GET, PUT, PATCH |
| 201 | Successful POST that creates a resource |
| 204 | Successful DELETE with no body |
| 400 | Malformed request, validation failure |
| 401 | Missing or invalid authentication |
| 403 | Authenticated but not authorized |
| 404 | Resource not found |
| 409 | Conflict (duplicate creation) |
| 429 | Rate limit exceeded |
| 500 | Unhandled server error |
| 502 | Bad gateway (upstream service failure) |
| 503 | Service unavailable (cold start timeout) |
Versioning Strategies
API versioning in serverless comes down to three practical approaches. I have used all three and each has trade-offs.
Path-based versioning is the simplest and most explicit. API Gateway maps /v1/users and /v2/users to different Lambda functions or different code paths within the same function.
// serverless.yml
// functions:
// usersV1:
// handler: handlers/users-v1.handler
// events:
// - http:
// path: /v1/users
// method: any
// usersV2:
// handler: handlers/users-v2.handler
// events:
// - http:
// path: /v2/users
// method: any
Header-based versioning uses a custom header like Accept-Version: 2 or the Accept header with a vendor media type. This keeps URLs clean but makes testing harder since you cannot just paste a URL into a browser.
Stage-based versioning leverages API Gateway stages. Your production API lives at api.example.com/prod/users and the next version at api.example.com/v2/users. This is the easiest to set up in API Gateway but tightly couples your versioning to your deployment infrastructure.
My recommendation: use path-based versioning. It is explicit, testable, and every developer understands it immediately.
Pagination in Serverless APIs
Offset-based pagination is fragile with DynamoDB and most NoSQL databases. Cursor-based pagination is the right pattern for serverless APIs. It performs consistently regardless of dataset size and works naturally with DynamoDB's LastEvaluatedKey.
var AWS = require("aws-sdk");
var dynamodb = new AWS.DynamoDB.DocumentClient();
function listItems(event) {
var queryParams = event.queryStringParameters || {};
var limit = Math.min(parseInt(queryParams.limit, 10) || 20, 100);
var cursor = queryParams.cursor || null;
var params = {
TableName: process.env.TABLE_NAME,
Limit: limit
};
// Decode cursor if provided
if (cursor) {
try {
var decoded = JSON.parse(Buffer.from(cursor, "base64").toString("utf8"));
params.ExclusiveStartKey = decoded;
} catch (err) {
return Promise.resolve(
errorResponse(400, "Invalid cursor format")
);
}
}
return dynamodb.scan(params).promise()
.then(function(result) {
var nextCursor = null;
if (result.LastEvaluatedKey) {
nextCursor = Buffer.from(
JSON.stringify(result.LastEvaluatedKey)
).toString("base64");
}
return successResponse(result.Items, {
count: result.Items.length,
cursor: nextCursor,
hasMore: !!result.LastEvaluatedKey
});
});
}
The cursor is an opaque, base64-encoded token from the client's perspective. They do not need to know it contains DynamoDB's LastEvaluatedKey internally. This gives you the flexibility to change your database without breaking the API contract.
Error Handling Patterns
Error handling in serverless requires a layered approach. You need to catch errors at the handler level, at the business logic level, and at the infrastructure level.
// Custom error classes
function AppError(message, statusCode, code) {
this.message = message;
this.statusCode = statusCode || 500;
this.code = code || "INTERNAL_ERROR";
this.name = "AppError";
}
AppError.prototype = Object.create(Error.prototype);
AppError.prototype.constructor = AppError;
function NotFoundError(resource) {
AppError.call(this, resource + " not found", 404, "NOT_FOUND");
}
NotFoundError.prototype = Object.create(AppError.prototype);
function ConflictError(message) {
AppError.call(this, message, 409, "CONFLICT");
}
ConflictError.prototype = Object.create(AppError.prototype);
// Centralized error handler wrapper
function withErrorHandling(handler) {
return function(event, context) {
context.callbackWaitsForEmptyEventLoop = false;
return Promise.resolve()
.then(function() {
return handler(event, context);
})
.catch(function(err) {
console.error("Handler error:", JSON.stringify({
error: err.message,
code: err.code,
stack: err.stack,
event: {
path: event.path,
method: event.httpMethod,
queryStringParameters: event.queryStringParameters
}
}));
if (err instanceof AppError) {
return errorResponse(err.statusCode, err.message, { code: err.code });
}
// DynamoDB specific errors
if (err.code === "ConditionalCheckFailedException") {
return errorResponse(409, "Resource already exists or was modified");
}
if (err.code === "ProvisionedThroughputExceededException") {
return errorResponse(429, "Too many requests, please retry");
}
// Generic fallback - never expose internal details
return errorResponse(500, "Internal server error");
});
};
}
// Usage
module.exports.handler = withErrorHandling(function(event, context) {
var userId = event.pathParameters.id;
if (!validator.isUUID(userId)) {
throw new AppError("Invalid user ID format", 400, "INVALID_ID");
}
return getUser(userId).then(function(user) {
if (!user) {
throw new NotFoundError("User");
}
return successResponse(user);
});
});
This pattern keeps handlers clean while ensuring no unhandled error ever leaks internal details to the client.
Authentication and Authorization
For serverless APIs, JWT-based authentication with Lambda authorizers is the standard approach. The authorizer runs before your handler function, so unauthorized requests never consume your function's compute time.
// authorizer.js - Lambda authorizer function
var jwt = require("jsonwebtoken");
var JWT_SECRET = process.env.JWT_SECRET;
module.exports.handler = function(event, context, callback) {
var token = extractToken(event.authorizationToken);
if (!token) {
return callback("Unauthorized");
}
try {
var decoded = jwt.verify(token, JWT_SECRET, {
algorithms: ["HS256"],
maxAge: "24h"
});
var policy = generatePolicy(decoded.sub, "Allow", event.methodArn, {
userId: decoded.sub,
email: decoded.email,
role: decoded.role || "user"
});
callback(null, policy);
} catch (err) {
console.error("Auth error:", err.message);
callback("Unauthorized");
}
};
function extractToken(authHeader) {
if (!authHeader) return null;
var parts = authHeader.split(" ");
if (parts.length !== 2 || parts[0] !== "Bearer") return null;
return parts[1];
}
function generatePolicy(principalId, effect, resource, context) {
var arnParts = resource.split(":");
var apiGatewayArn = arnParts[5].split("/");
var wildcardResource = arnParts[0] + ":" + arnParts[1] + ":" +
arnParts[2] + ":" + arnParts[3] + ":" + arnParts[4] + ":" +
apiGatewayArn[0] + "/" + apiGatewayArn[1] + "/*";
return {
principalId: principalId,
policyDocument: {
Version: "2012-10-17",
Statement: [{
Action: "execute-api:Invoke",
Effect: effect,
Resource: wildcardResource
}]
},
context: context
};
}
Inside your handler functions, the authorizer context is available on the event object:
module.exports.handler = withErrorHandling(function(event) {
var userId = event.requestContext.authorizer.userId;
var userRole = event.requestContext.authorizer.role;
// Role-based access control
if (userRole !== "admin") {
throw new AppError("Insufficient permissions", 403, "FORBIDDEN");
}
return adminOperation(userId);
});
Rate Limiting Without a Server
API Gateway provides built-in throttling, but it applies uniformly. For per-user or per-API-key rate limiting, you need to track request counts in a fast data store. DynamoDB with TTL is perfect for this.
var AWS = require("aws-sdk");
var dynamodb = new AWS.DynamoDB.DocumentClient();
var RATE_LIMIT_TABLE = process.env.RATE_LIMIT_TABLE;
function checkRateLimit(identifier, maxRequests, windowSeconds) {
var now = Math.floor(Date.now() / 1000);
var windowKey = identifier + ":" + Math.floor(now / windowSeconds);
var ttl = now + windowSeconds + 60; // Extra buffer for cleanup
var params = {
TableName: RATE_LIMIT_TABLE,
Key: { pk: windowKey },
UpdateExpression: "SET #count = if_not_exists(#count, :zero) + :one, #ttl = :ttl",
ExpressionAttributeNames: {
"#count": "requestCount",
"#ttl": "ttl"
},
ExpressionAttributeValues: {
":zero": 0,
":one": 1,
":ttl": ttl
},
ReturnValues: "ALL_NEW"
};
return dynamodb.update(params).promise()
.then(function(result) {
var currentCount = result.Attributes.requestCount;
var remaining = Math.max(0, maxRequests - currentCount);
return {
allowed: currentCount <= maxRequests,
remaining: remaining,
limit: maxRequests,
resetAt: (Math.floor(now / windowSeconds) + 1) * windowSeconds
};
});
}
// Middleware wrapper
function withRateLimit(handler, maxRequests, windowSeconds) {
return function(event, context) {
var identifier = event.requestContext.identity.sourceIp;
return checkRateLimit(identifier, maxRequests || 100, windowSeconds || 60)
.then(function(result) {
if (!result.allowed) {
var response = errorResponse(429, "Rate limit exceeded");
response.headers["X-RateLimit-Limit"] = String(result.limit);
response.headers["X-RateLimit-Remaining"] = "0";
response.headers["X-RateLimit-Reset"] = String(result.resetAt);
return response;
}
return handler(event, context).then(function(response) {
response.headers = response.headers || {};
response.headers["X-RateLimit-Limit"] = String(result.limit);
response.headers["X-RateLimit-Remaining"] = String(result.remaining);
response.headers["X-RateLimit-Reset"] = String(result.resetAt);
return response;
});
});
};
}
API Documentation with OpenAPI
Every serverless API should have an OpenAPI specification. It serves as the single source of truth for your API contract and can be used to generate client SDKs, validate requests at the gateway level, and produce interactive documentation.
openapi: 3.0.3
info:
title: Users API
version: 1.0.0
description: User management serverless API
paths:
/v1/users:
get:
summary: List users
parameters:
- name: limit
in: query
schema:
type: integer
minimum: 1
maximum: 100
default: 20
- name: cursor
in: query
schema:
type: string
responses:
"200":
description: List of users
content:
application/json:
schema:
type: object
properties:
success:
type: boolean
data:
type: array
items:
$ref: "#/components/schemas/User"
meta:
$ref: "#/components/schemas/PaginationMeta"
post:
summary: Create a user
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/CreateUserRequest"
responses:
"201":
description: User created
"400":
description: Validation error
"409":
description: User already exists
components:
schemas:
User:
type: object
properties:
id:
type: string
format: uuid
email:
type: string
format: email
name:
type: string
createdAt:
type: string
format: date-time
CreateUserRequest:
type: object
required: [email, name]
properties:
email:
type: string
format: email
name:
type: string
minLength: 2
maxLength: 100
PaginationMeta:
type: object
properties:
count:
type: integer
cursor:
type: string
nullable: true
hasMore:
type: boolean
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
Store this spec alongside your code and keep it updated with every API change. Tools like swagger-ui-express can serve it directly from a Lambda function if you need an interactive docs endpoint.
CORS Handling
CORS in API Gateway is one of the most common sources of frustration. The key insight is that API Gateway needs to handle the preflight OPTIONS request separately, and your Lambda response must include the right headers.
var ALLOWED_ORIGINS = (process.env.ALLOWED_ORIGINS || "").split(",");
function getCorsHeaders(event) {
var origin = "";
if (event.headers) {
origin = event.headers.origin || event.headers.Origin || "";
}
var allowedOrigin = ALLOWED_ORIGINS.indexOf(origin) !== -1
? origin
: ALLOWED_ORIGINS[0] || "*";
return {
"Access-Control-Allow-Origin": allowedOrigin,
"Access-Control-Allow-Headers": "Content-Type,Authorization,X-Api-Key",
"Access-Control-Allow-Methods": "GET,POST,PUT,DELETE,OPTIONS",
"Access-Control-Max-Age": "86400",
"Access-Control-Allow-Credentials": "true"
};
}
function handleCors(handler) {
return function(event, context) {
var corsHeaders = getCorsHeaders(event);
// Handle preflight
if (event.httpMethod === "OPTIONS") {
return Promise.resolve({
statusCode: 204,
headers: corsHeaders,
body: ""
});
}
return handler(event, context).then(function(response) {
response.headers = Object.assign({}, response.headers, corsHeaders);
return response;
});
};
}
Set Access-Control-Allow-Credentials to "true" only if you actually need cookies or auth headers. And never use "*" as the allowed origin in production—always whitelist specific domains.
Request and Response Compression
API Gateway handles gzip compression automatically for responses over 1 KB when the client sends Accept-Encoding: gzip. However, you need to enable it in your API Gateway settings and your Lambda function should be aware of it.
var zlib = require("zlib");
function compressResponse(response) {
var body = response.body;
if (!body || body.length < 1024) {
return Promise.resolve(response);
}
return new Promise(function(resolve, reject) {
zlib.gzip(Buffer.from(body, "utf8"), function(err, compressed) {
if (err) {
// Fall back to uncompressed
resolve(response);
return;
}
resolve({
statusCode: response.statusCode,
headers: Object.assign({}, response.headers, {
"Content-Encoding": "gzip"
}),
body: compressed.toString("base64"),
isBase64Encoded: true
});
});
});
}
For most APIs, let API Gateway handle compression and focus your Lambda code on keeping payloads small. Do not return 50 fields when the client needs 5. Support field selection when it makes sense:
function selectFields(item, fields) {
if (!fields || fields.length === 0) return item;
var result = {};
fields.forEach(function(field) {
if (item.hasOwnProperty(field)) {
result[field] = item[field];
}
});
return result;
}
// GET /users?fields=id,name,email
var fields = (queryParams.fields || "").split(",").filter(Boolean);
var users = results.map(function(user) {
return selectFields(user, fields);
});
API Testing Strategies
Testing serverless APIs requires three levels: unit tests for business logic, integration tests for the handler, and end-to-end tests against a deployed API.
// test/handlers/users.test.js
var assert = require("assert");
// Mock AWS SDK before requiring handler
var mockDynamoDB = {
get: function() {
return {
promise: function() {
return Promise.resolve({
Item: { id: "abc-123", name: "Test User", email: "[email protected]" }
});
}
};
},
put: function() {
return { promise: function() { return Promise.resolve({}); } };
}
};
// Simple mock injection
process.env.TABLE_NAME = "test-users";
process.env.JWT_SECRET = "test-secret";
var handler = require("../../handlers/users");
describe("Users Handler", function() {
describe("GET /users/:id", function() {
it("should return a user by ID", function() {
var event = {
httpMethod: "GET",
path: "/v1/users/abc-123",
pathParameters: { id: "abc-123" },
headers: {},
requestContext: {
authorizer: { userId: "abc-123", role: "user" }
}
};
return handler.getUser(event, {}).then(function(response) {
assert.strictEqual(response.statusCode, 200);
var body = JSON.parse(response.body);
assert.strictEqual(body.success, true);
assert.strictEqual(body.data.id, "abc-123");
});
});
it("should return 400 for invalid UUID", function() {
var event = {
httpMethod: "GET",
path: "/v1/users/not-a-uuid",
pathParameters: { id: "not-a-uuid" },
headers: {},
requestContext: {
authorizer: { userId: "abc-123", role: "user" }
}
};
return handler.getUser(event, {}).then(function(response) {
assert.strictEqual(response.statusCode, 400);
});
});
});
describe("POST /users", function() {
it("should reject invalid email", function() {
var event = {
httpMethod: "POST",
path: "/v1/users",
body: JSON.stringify({ email: "not-an-email", name: "Test" }),
headers: { "Content-Type": "application/json" },
requestContext: {
authorizer: { userId: "abc-123", role: "admin" }
}
};
return handler.createUser(event, {}).then(function(response) {
assert.strictEqual(response.statusCode, 400);
var body = JSON.parse(response.body);
assert.strictEqual(body.success, false);
});
});
});
});
For end-to-end tests, use a dedicated test stage:
// test/e2e/api.test.js
var https = require("https");
var API_BASE = process.env.API_BASE_URL || "https://api-test.example.com/v1";
var API_KEY = process.env.TEST_API_KEY;
function apiRequest(method, path, body) {
var url = new URL(API_BASE + path);
var options = {
method: method,
hostname: url.hostname,
path: url.pathname + url.search,
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer " + API_KEY
}
};
return new Promise(function(resolve, reject) {
var req = https.request(options, function(res) {
var data = "";
res.on("data", function(chunk) { data += chunk; });
res.on("end", function() {
resolve({
statusCode: res.statusCode,
headers: res.headers,
body: JSON.parse(data)
});
});
});
req.on("error", reject);
if (body) req.write(JSON.stringify(body));
req.end();
});
}
describe("Users API E2E", function() {
this.timeout(10000); // Account for cold starts
it("should create and retrieve a user", function() {
var testUser = {
email: "e2e-" + Date.now() + "@example.com",
name: "E2E Test User"
};
return apiRequest("POST", "/users", testUser)
.then(function(res) {
assert.strictEqual(res.statusCode, 201);
return apiRequest("GET", "/users/" + res.body.data.id);
})
.then(function(res) {
assert.strictEqual(res.statusCode, 200);
assert.strictEqual(res.body.data.name, "E2E Test User");
});
});
});
Complete Working Example
Here is a production-ready serverless API that ties together all the patterns discussed above. This is a user management API with CRUD operations, authentication, pagination, and validation.
// handlers/users.js
var AWS = require("aws-sdk");
var uuid = require("uuid");
var jwt = require("jsonwebtoken");
var dynamodb = new AWS.DynamoDB.DocumentClient();
var TABLE_NAME = process.env.TABLE_NAME || "users";
var JWT_SECRET = process.env.JWT_SECRET;
// ---- Response Helpers ----
function buildResponse(statusCode, body, extraHeaders) {
var headers = Object.assign({
"Content-Type": "application/json",
"Access-Control-Allow-Origin": process.env.ALLOWED_ORIGIN || "*",
"Access-Control-Allow-Headers": "Content-Type,Authorization",
"Access-Control-Allow-Methods": "GET,POST,PUT,DELETE,OPTIONS"
}, extraHeaders || {});
return {
statusCode: statusCode,
headers: headers,
body: JSON.stringify(body)
};
}
function success(data, meta) {
var body = { success: true, data: data };
if (meta) body.meta = meta;
return buildResponse(200, body);
}
function created(data) {
return buildResponse(201, { success: true, data: data });
}
function error(statusCode, message, details) {
return buildResponse(statusCode, {
success: false,
error: { message: message, details: details || null }
});
}
// ---- Validation ----
function validateUserInput(body) {
var errors = [];
if (!body.email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(body.email)) {
errors.push({ field: "email", message: "Valid email is required" });
}
if (!body.name || body.name.trim().length < 2) {
errors.push({ field: "name", message: "Name must be at least 2 characters" });
}
if (body.name && body.name.length > 100) {
errors.push({ field: "name", message: "Name cannot exceed 100 characters" });
}
return errors;
}
// ---- Auth Helper ----
function getAuthContext(event) {
var authorizer = (event.requestContext || {}).authorizer || {};
return {
userId: authorizer.userId || null,
role: authorizer.role || "user"
};
}
function requireRole(event, requiredRole) {
var auth = getAuthContext(event);
if (auth.role !== requiredRole) {
return error(403, "Forbidden: requires " + requiredRole + " role");
}
return null;
}
// ---- Route: Parse body safely ----
function parseBody(event) {
try {
return JSON.parse(event.body || "{}");
} catch (err) {
return null;
}
}
// ---- Handlers ----
function listUsers(event) {
var params = event.queryStringParameters || {};
var limit = Math.min(parseInt(params.limit, 10) || 20, 100);
var cursor = params.cursor || null;
var scanParams = {
TableName: TABLE_NAME,
Limit: limit
};
if (cursor) {
try {
scanParams.ExclusiveStartKey = JSON.parse(
Buffer.from(cursor, "base64").toString("utf8")
);
} catch (err) {
return Promise.resolve(error(400, "Invalid cursor"));
}
}
return dynamodb.scan(scanParams).promise()
.then(function(result) {
var nextCursor = null;
if (result.LastEvaluatedKey) {
nextCursor = Buffer.from(
JSON.stringify(result.LastEvaluatedKey)
).toString("base64");
}
return success(result.Items, {
count: result.Items.length,
cursor: nextCursor,
hasMore: !!result.LastEvaluatedKey
});
});
}
function getUser(event) {
var userId = event.pathParameters.id;
var uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (!uuidRegex.test(userId)) {
return Promise.resolve(error(400, "Invalid user ID format"));
}
return dynamodb.get({
TableName: TABLE_NAME,
Key: { id: userId }
}).promise()
.then(function(result) {
if (!result.Item) {
return error(404, "User not found");
}
return success(result.Item);
});
}
function createUser(event) {
var body = parseBody(event);
if (!body) {
return Promise.resolve(error(400, "Invalid JSON body"));
}
var validationErrors = validateUserInput(body);
if (validationErrors.length > 0) {
return Promise.resolve(error(400, "Validation failed", validationErrors));
}
var now = new Date().toISOString();
var user = {
id: uuid.v4(),
email: body.email.toLowerCase().trim(),
name: body.name.trim(),
createdAt: now,
updatedAt: now
};
return dynamodb.put({
TableName: TABLE_NAME,
Item: user,
ConditionExpression: "attribute_not_exists(id)"
}).promise()
.then(function() {
return created(user);
})
.catch(function(err) {
if (err.code === "ConditionalCheckFailedException") {
return error(409, "User already exists");
}
throw err;
});
}
function updateUser(event) {
var userId = event.pathParameters.id;
var body = parseBody(event);
if (!body) {
return Promise.resolve(error(400, "Invalid JSON body"));
}
var updateExpressions = [];
var attributeNames = {};
var attributeValues = {};
if (body.name) {
updateExpressions.push("#name = :name");
attributeNames["#name"] = "name";
attributeValues[":name"] = body.name.trim();
}
if (body.email) {
updateExpressions.push("#email = :email");
attributeNames["#email"] = "email";
attributeValues[":email"] = body.email.toLowerCase().trim();
}
if (updateExpressions.length === 0) {
return Promise.resolve(error(400, "No fields to update"));
}
updateExpressions.push("#updatedAt = :updatedAt");
attributeNames["#updatedAt"] = "updatedAt";
attributeValues[":updatedAt"] = new Date().toISOString();
return dynamodb.update({
TableName: TABLE_NAME,
Key: { id: userId },
UpdateExpression: "SET " + updateExpressions.join(", "),
ExpressionAttributeNames: attributeNames,
ExpressionAttributeValues: attributeValues,
ConditionExpression: "attribute_exists(id)",
ReturnValues: "ALL_NEW"
}).promise()
.then(function(result) {
return success(result.Attributes);
})
.catch(function(err) {
if (err.code === "ConditionalCheckFailedException") {
return error(404, "User not found");
}
throw err;
});
}
function deleteUser(event) {
var userId = event.pathParameters.id;
return dynamodb.delete({
TableName: TABLE_NAME,
Key: { id: userId },
ConditionExpression: "attribute_exists(id)"
}).promise()
.then(function() {
return buildResponse(204, "");
})
.catch(function(err) {
if (err.code === "ConditionalCheckFailedException") {
return error(404, "User not found");
}
throw err;
});
}
// ---- Router ----
module.exports.handler = function(event, context) {
context.callbackWaitsForEmptyEventLoop = false;
if (event.httpMethod === "OPTIONS") {
return Promise.resolve(buildResponse(204, ""));
}
var method = event.httpMethod;
var hasId = event.pathParameters && event.pathParameters.id;
return Promise.resolve()
.then(function() {
if (method === "GET" && !hasId) return listUsers(event);
if (method === "GET" && hasId) return getUser(event);
if (method === "POST" && !hasId) return createUser(event);
if (method === "PUT" && hasId) return updateUser(event);
if (method === "DELETE" && hasId) return deleteUser(event);
return error(405, "Method not allowed");
})
.catch(function(err) {
console.error("Unhandled error:", err);
return error(500, "Internal server error");
});
};
// Export individual handlers for testing
module.exports.getUser = function(event, context) {
return getUser(event);
};
module.exports.createUser = function(event, context) {
return createUser(event);
};
Deploy this with the Serverless Framework:
# serverless.yml
service: users-api
provider:
name: aws
runtime: nodejs18.x
region: us-east-1
environment:
TABLE_NAME: ${self:service}-${sls:stage}
JWT_SECRET: ${ssm:/users-api/jwt-secret}
ALLOWED_ORIGIN: https://app.example.com
iam:
role:
statements:
- Effect: Allow
Action:
- dynamodb:GetItem
- dynamodb:PutItem
- dynamodb:UpdateItem
- dynamodb:DeleteItem
- dynamodb:Scan
Resource:
- !GetAtt UsersTable.Arn
functions:
api:
handler: handlers/users.handler
events:
- http:
path: /v1/users
method: any
authorizer:
name: jwtAuth
identitySource: method.request.header.Authorization
- http:
path: /v1/users/{id}
method: any
authorizer:
name: jwtAuth
identitySource: method.request.header.Authorization
jwtAuth:
handler: handlers/authorizer.handler
resources:
Resources:
UsersTable:
Type: AWS::DynamoDB::Table
Properties:
TableName: ${self:service}-${sls:stage}
BillingMode: PAY_PER_REQUEST
AttributeDefinitions:
- AttributeName: id
AttributeType: S
KeySchema:
- AttributeName: id
KeyType: HASH
Common Issues and Troubleshooting
1. CORS preflight returns 403 Forbidden
Access to XMLHttpRequest at 'https://api.example.com/v1/users' from origin
'https://app.example.com' has been blocked by CORS policy: Response to preflight
request doesn't pass access control check: It does not have HTTP ok status.
This almost always means your OPTIONS handler is behind the Lambda authorizer. Preflight requests do not include Authorization headers, so the authorizer rejects them. Fix: exclude OPTIONS from the authorizer in your API Gateway configuration, or add cors: true to your Serverless Framework http event.
2. Lambda timeout with 502 Bad Gateway
{
"message": "Internal server error"
}
The API Gateway returns a generic 502 when your Lambda times out. Check CloudWatch logs for Task timed out after X seconds. Common causes: DynamoDB connections hanging due to VPC misconfiguration, or context.callbackWaitsForEmptyEventLoop not set to false causing the function to wait for open database connections.
3. Request body is null or undefined
TypeError: Cannot read property 'email' of null
at createUser (/var/task/handlers/users.js:84:20)
API Gateway does not always parse the request body for you. If the Content-Type header is missing or unexpected, event.body arrives as a raw string or null. Always parse defensively and handle the case where JSON.parse fails. Also check if event.isBase64Encoded is true—API Gateway sometimes base64-encodes the body.
4. DynamoDB ConditionalCheckFailedException on update
ConditionalCheckFailedException: The conditional request failed
at Request.extractError (/var/runtime/node_modules/aws-sdk/lib/protocol/json.js:52:27)
This happens when your ConditionExpression evaluates to false. In the context of updates, it usually means the item does not exist. For creates with attribute_not_exists, it means a duplicate. Always catch this specific error code and return the appropriate HTTP status (404 for missing items, 409 for duplicates).
5. Cold start causes API Gateway timeout
Endpoint request timed out
API Gateway has a hard 29-second timeout. If your Lambda cold start plus execution exceeds this, the client gets a timeout error even though the Lambda may still be running. Solutions: use provisioned concurrency for critical endpoints, reduce deployment package size, minimize module-scope initialization, and consider Lambda SnapStart if using a supported runtime.
Best Practices
Set
context.callbackWaitsForEmptyEventLoop = falsein every handler. Without this, your Lambda waits for all open connections (database, HTTP) to close before returning. This leads to unnecessary billing and intermittent timeouts.Return consistent response structures. Every endpoint, every status code, same JSON shape. Your consumers should never have to guess whether the response has a
datafield or aresultfield or anerrorfield.Use environment variables for all configuration. Table names, API keys, feature flags, CORS origins—nothing should be hardcoded. This enables per-stage configuration and keeps secrets out of your codebase.
Implement idempotency for write operations. Network retries happen. API Gateway retries happen. If a client sends the same POST twice, you should not create duplicate resources. Use conditional writes in DynamoDB or accept a client-provided idempotency key.
Log structured JSON, not strings. CloudWatch Logs Insights can query JSON fields directly.
console.log(JSON.stringify({ action: "createUser", userId: id, duration: ms }))is infinitely more useful thanconsole.log("Created user " + id).Keep deployment packages small. Every megabyte added to your package increases cold start time. Use
webpackoresbuildto bundle and tree-shake. Excludeaws-sdksince it is already available in the Lambda runtime. Target under 5 MB zipped for optimal cold start performance.Set appropriate timeouts per function. Not every Lambda needs the default 6-second timeout. A simple GET might need 3 seconds; a function that calls multiple downstream services might need 15. Set timeouts based on actual performance characteristics plus a buffer.
Use API Gateway request validators to reject obviously bad requests before they ever reach your Lambda function. This reduces invocation costs and provides better error messages to clients without writing any code.
References
- AWS Lambda Developer Guide - Official Lambda documentation covering runtimes, deployment, and configuration
- Amazon API Gateway REST API Reference - Complete API Gateway documentation including request validation and CORS
- Serverless Framework Documentation - Framework documentation for deploying serverless applications
- OpenAPI 3.0 Specification - The OpenAPI specification for documenting REST APIs
- DynamoDB Best Practices - AWS guidelines for DynamoDB table design and access patterns
- AWS Well-Architected Serverless Lens - Architectural best practices for serverless workloads