Building Serverless APIs on AWS

Lambda and API Gateway can take you a long way, but the sharp edges are real. Here's a practical guide to structuring a serverless API that's actually maintainable.

  • aws
  • serverless
  • backend

Serverless APIs on AWS are compelling for the right workloads: no servers to patch, automatic scaling, and you pay only for actual invocations. But “serverless” doesn’t mean “simple.” After running production Lambda APIs for a few years, here are the patterns that have served me well.

Structure Your Lambdas Around Domains, Not Routes

The tempting starting point is one Lambda per HTTP route. That works until you have 40 routes and 40 functions to manage, each with their own IAM role, environment variables, and deployment configuration. A better default is one Lambda per domain or bounded context — a single orders function that handles all order-related routes, dispatched internally.

export const handler = async (event: APIGatewayProxyEvent) => {
  const { httpMethod, path } = event;

  if (httpMethod === 'GET' && path === '/orders') return listOrders(event);
  if (httpMethod === 'POST' && path === '/orders') return createOrder(event);

  return { statusCode: 404, body: 'Not found' };
};

This keeps IAM boundaries meaningful and makes local development straightforward.

Cold Starts Are a Real Cost

Lambda cold starts — the delay when a function initialises for the first time — matter for user-facing APIs. Practical mitigations:

  • Keep bundles small. Tree-shake aggressively; don’t import entire AWS SDK v2 when you need one service.
  • Use Provisioned Concurrency for latency-sensitive paths. It pre-warms instances so cold starts don’t hit real users.
  • Prefer Node.js or Python for short-lived functions. JVM and .NET runtimes have longer cold start profiles.

Environment Configuration

Lambda environment variables are limited to 4KB total. For anything larger — feature flags, connection strings, certificates — use SSM Parameter Store or Secrets Manager and fetch at cold start, caching the value in module scope.

let cachedSecret: string;

async function getSecret(): Promise<string> {
  if (cachedSecret) return cachedSecret;
  const result = await ssm.getParameter({ Name: '/app/db-url', WithDecryption: true }).promise();
  cachedSecret = result.Parameter!.Value!;
  return cachedSecret;
}

Observability From Day One

The default Lambda logging experience (CloudWatch Logs) is workable but coarse. Add structured JSON logging from the start — it makes filtering and alerting far easier:

console.log(JSON.stringify({ level: 'info', orderId, action: 'created' }));

Pair that with Lambda Powertools for Python or TypeScript, which adds tracer, metrics, and logger utilities with minimal overhead.

Deployment

Terraform’s aws_lambda_function resource is reliable for managing functions as code. Keep function configs in a module so you can stamp out new ones without copy-pasting IAM boilerplate. For iterative dev, aws lambda update-function-code with a pre-built zip is faster than a full Terraform apply.

Serverless is not a silver bullet, but for event-driven workloads and APIs with variable traffic, it’s a genuinely good fit. The sharp edges are manageable once you know where they are.