Building a Serverless API with AWS Lambda and API Gateway
Lambda behind API Gateway is one of the fastest ways to get a production HTTP API running on AWS. There are no servers to patch, scaling is handled for you, and you pay per request instead of for idle capacity. The catch is that the simplicity of the first deploy hides a handful of decisions that matter later: which API Gateway flavour to use, how to structure handlers, how to authenticate, and how to see what is going on when something breaks.
This post builds a small "notes" API with the AWS CDK in TypeScript, a Node.js Lambda handler and a DynamoDB table, and then walks through the choices I would make before putting it in front of real traffic.
REST API or HTTP API
API Gateway offers two products for request/response APIs: the original REST API (v1) and the newer HTTP API (v2). They are configured differently and have different feature sets, so pick deliberately.
| Capability | REST API (v1) | HTTP API (v2) |
|---|---|---|
| Native JWT authorizer | No (use Cognito or Lambda authorizer) | Yes |
| Lambda authorizers | Yes | Yes |
| API keys and usage plans | Yes | No |
| Request validation against models | Yes | No |
| Built-in response caching | Yes | No |
| AWS WAF association | Yes | No |
| Private (VPC-only) endpoints | Yes | No |
| Cost and latency | Higher | Lower |
| Lambda payload format | 1.0 | 2.0 (default) |
My default is HTTP API. It is cheaper, simpler and has a first-class JWT authorizer, which covers most APIs fronted by Cognito, Auth0, Entra ID or any OIDC provider. I reach for REST API when I need usage plans for third-party API consumers, WAF directly on the API, request validation at the edge, or a private API reachable only from a VPC.
Project layout
Keep infrastructure and application code in one repository but in separate folders. The CDK app owns the stack; the handlers are plain TypeScript modules that can be unit tested without AWS.
notes-api/
bin/app.ts # CDK entry point
lib/notes-stack.ts # infrastructure
src/handlers/notes.ts # Lambda handler
src/lib/repo.ts # data access
test/
package.json
cdk.json
Bootstrap the account once per region, then deploy:
npm install aws-cdk-lib constructs
npm install @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb
npm install -D @types/aws-lambda esbuild
npx cdk bootstrap aws://123456789012/ap-southeast-1
npx cdk deploy NotesStack
Defining the infrastructure with CDK
NodejsFunction bundles the handler with esbuild, so you do not ship node_modules or dev dependencies. The HTTP API constructs live in aws-cdk-lib/aws-apigatewayv2 and the Lambda integration in aws-cdk-lib/aws-apigatewayv2-integrations.
import { Stack, StackProps, Duration, RemovalPolicy, CfnOutput } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs';
import { HttpApi, HttpMethod, CorsHttpMethod } from 'aws-cdk-lib/aws-apigatewayv2';
import { HttpLambdaIntegration } from 'aws-cdk-lib/aws-apigatewayv2-integrations';
import { HttpJwtAuthorizer } from 'aws-cdk-lib/aws-apigatewayv2-authorizers';
export class NotesStack extends Stack {
constructor(scope: Construct, id: string, props?: StackProps) {
super(scope, id, props);
const table = new dynamodb.Table(this, 'NotesTable', {
partitionKey: { name: 'pk', type: dynamodb.AttributeType.STRING },
sortKey: { name: 'sk', type: dynamodb.AttributeType.STRING },
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
pointInTimeRecovery: true,
removalPolicy: RemovalPolicy.RETAIN,
});
const notesFn = new NodejsFunction(this, 'NotesFn', {
entry: 'src/handlers/notes.ts',
runtime: lambda.Runtime.NODEJS_20_X,
architecture: lambda.Architecture.ARM_64,
memorySize: 512,
timeout: Duration.seconds(10),
environment: { TABLE_NAME: table.tableName },
bundling: { minify: true, sourceMap: true },
});
table.grantReadWriteData(notesFn);
const authorizer = new HttpJwtAuthorizer(
'JwtAuthorizer',
'https://cognito-idp.ap-southeast-1.amazonaws.com/ap-southeast-1_EXAMPLE',
{ jwtAudience: ['my-app-client-id'] },
);
const api = new HttpApi(this, 'NotesApi', {
corsPreflight: {
allowOrigins: ['https://app.example.com'],
allowMethods: [CorsHttpMethod.GET, CorsHttpMethod.POST, CorsHttpMethod.DELETE],
allowHeaders: ['authorization', 'content-type'],
maxAge: Duration.hours(1),
},
});
const integration = new HttpLambdaIntegration('NotesIntegration', notesFn);
api.addRoutes({
path: '/notes',
methods: [HttpMethod.GET, HttpMethod.POST],
integration,
authorizer,
});
api.addRoutes({
path: '/notes/{id}',
methods: [HttpMethod.GET, HttpMethod.DELETE],
integration,
authorizer,
});
new CfnOutput(this, 'ApiUrl', { value: api.apiEndpoint });
}
}
A few choices worth calling out. table.grantReadWriteData generates a scoped IAM policy instead of a hand-written wildcard. ARM64 (Graviton) is usually cheaper per unit of compute for Node.js workloads with no code changes. RemovalPolicy.RETAIN stops a cdk destroy from deleting your data.
Writing the handler
With HTTP API and payload format 2.0, the event type is APIGatewayProxyEventV2WithJWTAuthorizer when a JWT authorizer is attached, which gives you typed access to the validated claims.
import type {
APIGatewayProxyEventV2WithJWTAuthorizer,
APIGatewayProxyResultV2,
} from 'aws-lambda';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, PutCommand, QueryCommand } from '@aws-sdk/lib-dynamodb';
import { randomUUID } from 'node:crypto';
// Created once per execution environment and reused across invocations.
const doc = DynamoDBDocumentClient.from(new DynamoDBClient({}));
const TABLE = process.env.TABLE_NAME!;
const json = (statusCode: number, body: unknown): APIGatewayProxyResultV2 => ({
statusCode,
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
export const handler = async (
event: APIGatewayProxyEventV2WithJWTAuthorizer,
): Promise<APIGatewayProxyResultV2> => {
const userId = event.requestContext.authorizer.jwt.claims.sub as string;
try {
switch (event.routeKey) {
case 'GET /notes': {
const res = await doc.send(new QueryCommand({
TableName: TABLE,
KeyConditionExpression: 'pk = :pk',
ExpressionAttributeValues: { ':pk': `USER#${userId}` },
Limit: 50,
}));
return json(200, res.Items ?? []);
}
case 'POST /notes': {
const input = JSON.parse(event.body ?? '{}');
if (typeof input.text !== 'string' || input.text.length === 0) {
return json(400, { message: 'text is required' });
}
const note = { pk: `USER#${userId}`, sk: `NOTE#${randomUUID()}`, text: input.text,
createdAt: new Date().toISOString() };
await doc.send(new PutCommand({ TableName: TABLE, Item: note }));
return json(201, note);
}
default:
return json(404, { message: 'Not found' });
}
} catch (err) {
console.error('Unhandled error', err);
return json(500, { message: 'Internal server error' });
}
};
Two habits pay off here. First, create SDK clients outside the handler so warm invocations reuse connections. Second, key every query on the authenticated subject from the token, never on a user ID sent in the request body.
Note that event.body may be base64-encoded when isBase64Encoded is true, which happens with binary content types. If you accept uploads or non-text payloads, decode it before parsing.
One function or many
You can map every route to a single "Lambda-lith" (as above) or give each route its own function. A single function means fewer cold starts overall, one deployment artifact and simple local routing. Per-route functions give you tighter IAM permissions, independent memory and timeout settings, and smaller bundles.
A reasonable middle ground is one function per bounded resource: all note operations in one function, billing in another. Split further only when a route has clearly different resource needs or permission requirements.
Authentication and authorization
The JWT authorizer validates the token signature against the issuer's JWKS, checks expiry and audience, and rejects bad requests before your function runs, so you do not pay for those invocations. For scope-based access, add authorizationScopes to a route so only tokens carrying that scope reach it.
Authorization of individual resources still belongs in your code. The authorizer tells you who the caller is; your handler decides whether that caller can read note 123. Designing keys around the owner (as with USER#<sub> above) makes this check hard to forget.
If you need custom logic, such as validating an opaque token or an API key stored in your own database, use a Lambda authorizer and enable result caching so you are not invoking it on every request.
Throttling and protecting downstream
Lambda scales quickly, which is great until it overwhelms a database that does not. Put limits in more than one place:
- Stage or route throttling on the API to cap request rate and burst.
- Reserved concurrency on the function to cap how many invocations run at once.
- Timeouts that are shorter than the API Gateway integration timeout, so the function fails cleanly instead of the client receiving a gateway timeout.
# Default route throttling for the $default stage of an HTTP API
aws apigatewayv2 update-stage \
--api-id a1b2c3d4e5 \
--stage-name '$default' \
--default-route-settings ThrottlingBurstLimit=200,ThrottlingRateLimit=100
# Cap concurrent executions for the function
aws lambda put-function-concurrency \
--function-name NotesStack-NotesFn \
--reserved-concurrent-executions 50
Check the current account-level concurrency and API Gateway quotas in the Service Quotas console before launch; reserved concurrency is carved out of the regional account limit.
Observability
Lambda sends logs to CloudWatch Logs automatically. Make them useful by logging structured JSON with a request ID, and by enabling API Gateway access logs so you can correlate edge errors (401, 429, 5xx from the integration) with function logs.
# Stream function logs while testing
aws logs tail /aws/lambda/NotesStack-NotesFn --follow --format short
# Invoke directly with a test event
aws lambda invoke \
--function-name NotesStack-NotesFn \
--cli-binary-format raw-in-base64-out \
--payload file://events/get-notes.json \
response.json
Powertools for AWS Lambda (TypeScript) is worth adopting early. Its Logger, Tracer and Metrics utilities give you structured logs, X-Ray tracing and CloudWatch Embedded Metric Format metrics with very little code. Alarm on the function's Errors and Throttles metrics and on the API's 5xx rate.
Cold starts and performance
A cold start happens when Lambda creates a new execution environment. For Node.js with a bundled, minified handler, it is usually small, but it grows with bundle size and with work done at module load.
Practical ways to keep it down:
- Bundle with esbuild and import only the AWS SDK v3 clients you use.
- Avoid heavy initialisation at module scope unless it is reused (SDK clients are fine; loading a large config file on every cold start is not).
- Tune memory: CPU scales with memory, so a higher setting often makes the function faster and not more expensive.
- Use provisioned concurrency only for latency-sensitive paths where you have measured a real problem, since you pay for it while idle.
Production checklist
- Custom domain with an ACM certificate instead of the default
execute-apiURL. - CORS restricted to known origins, not
*, for authenticated APIs. - Least-privilege IAM via CDK
grant*methods. - Structured logs, access logs, and alarms on errors, throttles and latency.
- Throttling at the API and reserved concurrency on functions that talk to fragile dependencies.
- Separate stages or, better, separate AWS accounts for dev and prod.
- Point-in-time recovery on the DynamoDB table.
Conclusion
Lambda with API Gateway gets you a scalable API with very little operational work, but the defaults are only a starting point. Choose HTTP API unless you need REST API features, use CDK so permissions and wiring live in code, keep authorization decisions in your handler, and put limits in front of anything that cannot scale as fast as Lambda. Add structured logging and alarms before launch rather than after the first incident, and the platform will stay boring in the best way.