Building a Serverless API with AWS Lambda and API Gateway

TQ
Tran Quang
September 4, 2026 · 9 min read
#aws#lambda#api-gateway#serverless

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.

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

bash
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.

typescript
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.

typescript
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.
bash
# 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.

bash
# 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-api URL.
  • 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.

Found this useful?

Share it with someone who might need it.

Related articles All articles