avocadoo
All postsCloud

Building a Serverless API with AWS Lambda and API Gateway

A practical walkthrough of building a serverless HTTP API with AWS Lambda, API Gateway and DynamoDB using the AWS CDK in TypeScript, covering handler design, authentication, CORS, throttling, observability and the production pitfalls worth knowing up front.

Tran Quang · Sep 4, 2026 · 8 min read

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.

CapabilityREST API (v1)HTTP API (v2)
Native JWT authorizerNo (use Cognito or Lambda authorizer)Yes
Lambda authorizersYesYes
API keys and usage plansYesNo
Request validation against modelsYesNo
Built-in response cachingYesNo
AWS WAF associationYesNo
Private (VPC-only) endpointsYesNo
Cost and latencyHigherLower
Lambda payload format1.02.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:

# 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:

Production checklist

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.