Lambda Handler Patterns
Write Lambda handlers that are typed, idempotent, and init-optimized for Node.js 24 on AWS.
Recipe
Quick-reference recipe card - copy-paste ready.
import type { APIGatewayProxyHandlerV2, Context } from "aws-lambda";
// Init phase (cold start) - runs once per execution environment
const tableName = process.env.TABLE_NAME!;
export const handler: APIGatewayProxyHandlerV2 = async (event, context) => {
context.callbackWaitsForEmptyEventLoop = false;
const id = event.pathParameters?.id;
if (!id) {
return { statusCode: 400, body: JSON.stringify({ error: "id required" }) };
}
const item = await getItem(tableName, id);
return {
statusCode: item ? 200 : 404,
body: JSON.stringify(item ?? { error: "not found" }),
};
};
async function getItem(table: string, id: string) {
return { id, table };
}When to reach for this: Every new Lambda function before adopting framework wrappers.
Working Example
// src/handlers/orders.ts
import type {
APIGatewayProxyEventV2,
APIGatewayProxyResultV2,
Context,
SQSBatchResponse,
SQSEvent,
} from "aws-lambda";
import { processOrder } from "../services/orders.js";
// ---- HTTP API handler ----
export async function httpHandler(
event: APIGatewayProxyEventV2,
context: Context
): Promise<APIGatewayProxyResultV2> {
context.callbackWaitsForEmptyEventLoop = false;
if (event.requestContext.http.method === "POST" && event.rawPath === "/orders") {
const body = event.body ? JSON.parse(event.body) : {};
const order = await processOrder(body);
return { statusCode: 201, body: JSON.stringify(order) };
}
return { statusCode: 404, body: JSON.stringify({ error: "not found" }) };
}
// ---- SQS batch with partial failures ----
export async function sqsHandler(event: SQSEvent): Promise<SQSBatchResponse> {
const batchItemFailures: { itemIdentifier: string }[] = [];
await Promise.all(
event.Records.map(async (record) => {
try {
const msg = JSON.parse(record.body) as { orderId: string };
await processOrder(msg);
} catch {
batchItemFailures.push({ itemIdentifier: record.messageId });
}
})
);
return { batchItemFailures };
}What this demonstrates:
callbackWaitsForEmptyEventLoop = falseends invocation when handler returns (do not wait on open DB pool timers)- Typed
APIGatewayProxyEventV2for HTTP API - SQS partial batch failure response for retries without reprocessing successes
Deep Dive
Handler Styles
| Style | Status | Notes |
|---|---|---|
async (event) => {} | Preferred | Return value becomes response |
async (event, context, callback) => {} | Legacy | Avoid in new TypeScript code |
| Callback-only sync | Deprecated | No promise support |
Init vs Invoke Phase
// INIT (outside handler) - cache clients, parse env
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
const ddb = new DynamoDBClient({});
export const handler = async () => {
// INVOKE (inside handler) - per-request logic only
await ddb.send(/* ... */);
};Lambda reuses the execution environment across invocations. Put expensive setup in module scope.
Context Fields You Actually Use
export const handler = async (_event: unknown, context: Context) => {
console.log(JSON.stringify({
awsRequestId: context.awsRequestId,
functionName: context.functionName,
remainingMs: context.getRemainingTimeInMillis(),
}));
};getRemainingTimeInMillis() guards long loops before hard timeout.
Event Source Summary
| Source | Type import | Gotcha |
|---|---|---|
| HTTP API | APIGatewayProxyEventV2 | Base64 bodies |
| REST API | APIGatewayProxyEvent | v1 vs v2 types differ |
| SQS | SQSEvent | At-least-once delivery |
| S3 | S3Event | Event per object |
| EventBridge | EventBridgeEvent<string, T> | detail payload typing |
Gotchas
- New DB pool per invocation inside handler - exhausts connections. Fix: module-scope pool with low
max, or RDS Proxy. - Forgetting
callbackWaitsForEmptyEventLoop = false- hangs until pool idle. Fix: set false orawait pool.end()(usually wrong for reuse). - Non-idempotent SQS handlers - duplicate charges. Fix: idempotency keys in DynamoDB.
- Throwing on 4xx validation errors - Lambda marks invocation failed; may retry. Fix: return 4xx response, do not throw.
- Large
eventlogging - PII in CloudWatch. Fix: log ids and metadata only. - Mixing API Gateway v1 and v2 types - compile passes, runtime field missing. Fix: match integration type in IaC.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| Raw typed handlers | Full control, smallest bundle | Team wants Express routing |
| serverless-http | Lift Express app quickly | Cold start sensitive APIs |
| @fastify/aws-lambda | Fastify with Lambda bridge | Already on Express |
| Lambda Function URLs | Simple public HTTP without API GW | Need WAF, throttling, API keys |
FAQs
Should handlers return or throw on 500?
Return statusCode: 500 for HTTP APIs. Throw only when you want Lambda to retry (async invocations) or mark the batch item failed.
How do I type custom event detail?
EventBridgeEvent<"OrderCreated", { orderId: string }> and narrow on event["detail-type"].
One handler file or many?
One exported handler per Lambda function in AWS. Share code via services/ imports; do not multiplex unrelated triggers in one handler unless using a router library.
Does NestJS change the pattern?
@nestjs/platform-aws-lambda wraps bootstrap. Still set callbackWaitsForEmptyEventLoop in the adapter bootstrap.
How do I test handlers locally?
Import handler and pass fixture events from src/__fixtures__/apigw-v2-get.json. No need to start SAM for unit tests.
What about Middy middleware?
@middy/core wraps handlers for JSON parsing, CORS, and error normalization. Useful for raw handlers; less needed with Express/Fastify adapters.
Related
- Serverless Basics - Lambda vs containers
- Cold Start Mitigation - init optimization
- @aws-sdk v3 - clients in init phase
- serverless-express / aws-lambda-fastify - framework wrappers
- Serverless Best Practices - section checklist
Stack versions: This page was written for Node.js 24.18.0 (Active LTS), npm 10+, TypeScript 5.6+, Express 5, Fastify 5, and NestJS 11.