Build Serverless APIs with API Gateway
How to design, deploy, and manage serverless HTTP APIs using AWS API Gateway, Lambda, and function-as-a-service patterns.
Overview
Serverless computing lets you run code without provisioning or managing servers. AWS Lambda executes your functions in response to events, and API Gateway acts as the HTTP front door that routes requests to those functions. Together they form a pay-per-request, auto-scaling API platform that eliminates idle server costs.
This architecture is ideal for APIs with variable or unpredictable traffic. A startup might serve a hundred requests per day at launch and a million per day six months later — serverless handles both without capacity planning. The trade-offs are cold start latency (the delay when a function wakes up after being idle) and vendor-specific tooling that creates lock-in.
When to Use
- Building APIs with sporadic or unpredictable traffic patterns. See Rate Limiting for protecting APIs under load.
- Prototyping products where server costs should scale to zero when idle.
- Processing webhooks, file uploads, or scheduled events via HTTP. See Input Validation for validating incoming requests.
- Creating microservices where each endpoint has different resource needs.
Skip it for sustained high-traffic APIs where a warm container is cheaper, and for long-running work above Lambda’s 15-minute limit — that belongs on ECS/Fargate or a batch system.
Solution
AWS Lambda Handler (Python)
import json
def lambda_handler(event, context):
# API Gateway passes HTTP data in the event object
method = event['httpMethod']
path = event['path']
query = event.get('queryStringParameters', {})
if method == 'GET' and path == '/users':
return {
'statusCode': 200,
'headers': {'Content-Type': 'application/json'},
'body': json.dumps({'users': ['alice', 'bob']})
}
if method == 'POST' and path == '/users':
body = json.loads(event['body'])
return {
'statusCode': 201,
'body': json.dumps({'id': '123', 'name': body['name']})
}
return {'statusCode': 404, 'body': json.dumps({'error': 'Not found'})}
AWS Lambda Handler (Node.js)
exports.handler = async (event) => {
const { httpMethod, path, body } = event;
if (httpMethod === 'GET' && path === '/users') {
return {
statusCode: 200,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ users: ['alice', 'bob'] })
};
}
if (httpMethod === 'POST' && path === '/users') {
try {
const data = JSON.parse(body);
return {
statusCode: 201,
body: JSON.stringify({ id: '123', name: data.name })
};
} catch {
return { statusCode: 400, body: JSON.stringify({ error: 'Invalid JSON body' }) };
}
}
return { statusCode: 404, body: JSON.stringify({ error: 'Not found' }) };
};
Without that try/catch, a malformed body throws and API Gateway returns a generic 502 — a 400 tells the client what it actually did wrong.
Terraform Deployment
A working API needs five pieces the minimal examples usually skip: the IAM role, the AWS_PROXY integration, the permission that lets API Gateway invoke the function, and a deployment with a stage.
resource "aws_iam_role" "lambda_role" {
name = "users-lambda-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = { Service = "lambda.amazonaws.com" }
}]
})
}
resource "aws_iam_role_policy_attachment" "basic" {
role = aws_iam_role.lambda_role.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
resource "aws_lambda_function" "handler" {
function_name = "users-handler"
handler = "index.handler"
runtime = "nodejs20.x"
filename = "function.zip"
source_code_hash = filebase64sha256("function.zip")
role = aws_iam_role.lambda_role.arn
}
resource "aws_api_gateway_rest_api" "api" {
name = "users-api"
}
resource "aws_api_gateway_resource" "users" {
rest_api_id = aws_api_gateway_rest_api.api.id
parent_id = aws_api_gateway_rest_api.api.root_resource_id
path_part = "users"
}
resource "aws_api_gateway_method" "get_users" {
rest_api_id = aws_api_gateway_rest_api.api.id
resource_id = aws_api_gateway_resource.users.id
http_method = "GET"
authorization = "NONE"
}
resource "aws_api_gateway_integration" "get_users" {
rest_api_id = aws_api_gateway_rest_api.api.id
resource_id = aws_api_gateway_resource.users.id
http_method = aws_api_gateway_method.get_users.http_method
type = "AWS_PROXY"
integration_http_method = "POST"
uri = aws_lambda_function.handler.invoke_arn
}
resource "aws_lambda_permission" "apigw" {
statement_id = "AllowAPIGatewayInvoke"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.handler.function_name
principal = "apigateway.amazonaws.com"
source_arn = "${aws_api_gateway_rest_api.api.execution_arn}/*/*"
}
resource "aws_api_gateway_deployment" "deploy" {
rest_api_id = aws_api_gateway_rest_api.api.id
stage_name = "prod"
depends_on = [aws_api_gateway_integration.get_users]
}
output "invoke_url" {
value = "https://${aws_api_gateway_rest_api.api.id}.execute-api.${var.region}.amazonaws.com/prod"
}
AWS_PROXY is the key choice here: it forwards the whole HTTP request to Lambda and expects the {statusCode, headers, body} response shape the handlers return — no mapping templates to maintain.
If you apply this and the endpoint returns 500 Internal Server Error, the usual suspect is the missing aws_lambda_permission — API Gateway needs explicit permission to invoke the function and the error it surfaces doesn’t say so. Check aws lambda get-policy on the function, and look at the API Gateway access logs for Execution failed due to configuration error while you’re at it.
Explanation
- API Gateway: Receives HTTP requests, handles TLS termination, throttling, caching, and authentication, then invokes the Lambda function with the request data in a structured event object.
- Lambda: Event-driven function execution, stateless by design. AWS handles the scaling — 1,000 simultaneous requests mean 1,000 function instances running in parallel, and you pay for execution time and memory, nothing else.
- Cold starts: When a function hasn’t been invoked recently, AWS initializes a fresh runtime instance — expect 100ms to 2s added to that first call depending on the runtime and the memory you assigned. Provisioned concurrency keeps functions warm for latency-sensitive endpoints — see Cold Start Optimization for the full playbook.
- Infrastructure as Code: Terraform, AWS SAM, or the Serverless Framework express your API routes, functions, IAM roles, and environment variables as version-controlled config instead of console clicks.
Variants
| Platform | API Gateway | Function Runtime | Best For |
|---|---|---|---|
| AWS | API Gateway + Lambda | Python, Node, Java, Go | Mature platform, broad integrations |
| Azure | API Management + Functions | .NET, Node, Python | Microsoft ecosystem, Visual Studio integration |
| GCP | Cloud Endpoints + Cloud Functions | Node, Python, Go | BigQuery integration, competitive pricing |
API Gateway also supports WebSocket APIs for bidirectional traffic — see Real-time WebSockets — and Lambda authorizers for custom token validation before the request ever reaches your handler.
Production Considerations
The boilerplate checklists for serverless APIs all say the same things — these are the ones that actually bite in production:
- Authentication:
authorization = "NONE"is fine for a demo and wrong for a real API. Attach a Lambda authorizer (see the companion recipe) or Cognito/IAM for anything user-facing; use OAuth 2.0 flows for third-party clients. - Secrets: never bake credentials into the deployment package or plain environment variables — use AWS Secrets Manager (Azure Key Vault, Google Secret Manager on the other clouds) and rotate them.
- Idempotency: gateways and clients retry failed requests whether you like it or not — a POST that isn’t idempotent creates duplicates you then have to explain. Key writes on a client-supplied idempotency token or deduplication ID.
- Partial failures: with SQS-triggered functions, a single bad record fails the whole batch. Return
batchItemFailuresso only the poison message is retried — and configure a dead letter queue for what keeps failing. - VPC access: functions that touch private resources need VPC configuration, which changes cold start behavior and costs a NAT gateway for outbound internet. Don’t default to it — add it when you actually need private subnets.
- Testing: mock AWS SDK calls for unit tests (SAM CLI or LocalStack emulate API Gateway + Lambda locally), run integration tests against a real deployed stage, and load test before launch — the “it scales automatically” promise hides throttling and concurrency limits that only show under real traffic.
- Gradual rollouts: use Lambda versions and aliases with canary traffic shifting (SAM supports it natively) instead of updating
$LATESTin place.
What Works
- Design for statelessness: anything your function stashes in memory may vanish on the next invocation — pass state through the request or an external store.
- Minimize deployment package size: large packages increase cold start time.
- Set appropriate timeouts and memory: memory scales CPU proportionally. If a function is slow, increasing memory may be cheaper than paying for longer execution at lower memory.
- Use environment variables for config: database URLs and feature flags should be set via environment variables, not baked into the deployment package.
- Log as JSON, not prose: emit one JSON object per request carrying its request ID — that’s what makes
fieldsqueries in CloudWatch Logs Insights actually useful when you’re chasing a bug. - Use dead letter queues: failed async invocations are retried automatically. A DLQ captures persistent failures so you can inspect and reprocess them.
Common Mistakes
- Treating Lambda like a long-running server: functions hard-stop at 15 minutes, mid-work if necessary. Move anything longer to batch processing (AWS Batch) or containers (ECS/Fargate).
- Ignoring cold starts: a user-facing API with 3-second cold starts makes for a terrible experience. Use provisioned concurrency or a keep-alive ping for latency-sensitive endpoints.
- Over-provisioning memory: memory scales linearly with the bill, and most handlers never touch the 1,024 MB someone typed in on day one. Profile yours and allocate what it actually uses.
- Hardcoding credentials: never commit AWS keys or database passwords to your repository.
- Not handling partial failures: in batch processing (SQS triggers), a single bad record can cause the entire batch to fail.
Further Reading
- AWS docs — Building Lambda functions: the handler signature and runtime model both examples follow.
- AWS docs — API Gateway REST APIs: integrations, stages, and proxy behavior in depth.
- AWS docs — Lambda function scaling: concurrency, throttling, and provisioned concurrency mechanics.
- AWS SAM specification: the alternative IaC path with native canary deployments.
- AWS Well-Architected — Serverless Lens: the framework the Production Considerations section distills.
Frequently Asked Questions
How do I handle database connections in Lambda?
Use connection pooling with a lightweight proxy like RDS Proxy, or implement your own connection reuse logic — open the connection once outside the handler so warm invocations reuse it. Opening a new database connection on every invocation is slow and can exhaust the database's connection limit.
Can I run a full-stack application on serverless?
Yes, but evaluate the trade-offs. Static sites and APIs are excellent fits. Long-running WebSocket connections or stateful sessions may be better served by containers or EC2.
Is serverless cheaper than traditional servers?
It comes down to your traffic shape: sporadic traffic makes serverless cheap because you pay per request, while sustained high traffic tips the scale toward provisioned containers or EC2. Model your actual volumes with the AWS pricing calculator before committing — the crossover point surprises most teams.
How do I test Lambda functions locally?
Use the AWS SAM CLI or the Serverless Framework to emulate API Gateway and Lambda locally. These tools mount your code in a Docker container that matches the AWS runtime environment.
What's the difference between API Gateway REST and HTTP APIs?
HTTP APIs are newer, cheaper, and faster — they support OIDC/JWT authorization and cost about a third of REST APIs. REST APIs add capabilities HTTP APIs lack: API keys, usage plans, request validation, caching, and private endpoints. For most new APIs, start with HTTP API; reach for REST when you need those extras.
How do I add authentication to an API Gateway API?
Three common paths: a Lambda authorizer (custom token validation with your own logic), Cognito user pools (managed identity), or IAM authentication for internal service-to-service calls. The Lambda authorizer approach is covered in the companion recipe.
Related Resources
Secure API Gateway with Custom Lambda Authorizers
Implement custom Lambda authorizers for API Gateway with JWT validation, IAM policy generation, and caching for token-based authentication in serverless APIs.
RecipeMinimize Cold Start Latency in Serverless Functions
How to reduce cold start times in AWS Lambda, Azure Functions, and Cloud Run using provisioned concurrency, lazy loading, runtime tuning, and dependency optimization.
RecipeEvent-Driven Serverless Functions: Lambda & EventBridge
How to design loosely coupled systems using serverless functions triggered by events from message queues, databases, and webhooks.
RecipeBuild Real-Time APIs with WebSockets on Serverless
How to implement bidirectional real-time communication using WebSockets with AWS API Gateway, Lambda, DynamoDB, and what works in connection management.
RecipeRate Limiting
How to implement API rate limiting using token bucket, sliding window, and fixed window algorithms across Python, JavaScript, and Java.
RecipeInput Validation: Schema-First Examples in 3 Languages
Validate user input at the API boundary with schema libraries. Pydantic, Zod, and Jakarta Validation examples with sanitization and structured errors.