JSON in AWS Lambda: Event Formats, Response Schema & Payload Limits
Last updated:
AWS Lambda receives and returns all data as JSON, but the exact shape of that JSON varies dramatically depending on the trigger: an API Gateway v1 REST API event looks nothing like an SQS event or an S3 notification. The body field in API Gateway events is always a JSON string that you must parse manually — returning a plain object instead of JSON.stringify()-ed string from your handler causes API Gateway to respond with a 502 error. Synchronous invocations are capped at 6 MB for both request and response; async triggers (S3, SNS, EventBridge) cap at 256 KB. JSON schema validation compiled inside the handler function adds cold start latency on every container initialization. This guide covers Lambda event JSON format by trigger, API Gateway proxy integration specifics, response schema, payload size limits, parsing and validation patterns, Lambda Destinations routing, and cold start optimization for JSON-heavy functions.
Lambda JSON Event Format by Trigger
Each AWS service that invokes Lambda sends a distinct JSON event structure. Understanding the shape of each trigger's event is essential before writing any parsing code. The key fields differ not just in content but in nesting depth, array vs. object representation, and whether the payload requires further JSON parsing.
// ── API Gateway v1 REST API Proxy Event ────────────────────────────────
{
"version": "1.0", // implicit — v1 events may omit this
"resource": "/users/{id}",
"path": "/users/42",
"httpMethod": "POST",
"headers": {
"Content-Type": "application/json",
"Authorization": "Bearer eyJ..."
},
"multiValueHeaders": {
"Accept": ["application/json", "text/html"]
},
"queryStringParameters": { "include": "address" },
"multiValueQueryStringParameters": { "tag": ["a", "b"] },
"pathParameters": { "id": "42" },
"stageVariables": { "env": "prod" },
"body": "{"name":"Alice","email":"alice@example.com"}",
// body is ALWAYS a string — JSON.parse() required
"isBase64Encoded": false,
"requestContext": {
"accountId": "123456789012",
"stage": "prod",
"resourceId": "abc123",
"requestId": "req-uuid",
"identity": { "sourceIp": "1.2.3.4", "userAgent": "axios/1.6" },
"domainName": "api.example.com"
}
}
// ── API Gateway v2 HTTP API Proxy Event (payload format 2.0) ───────────
{
"version": "2.0", // always present; detect with event.version
"routeKey": "POST /users/{id}",
"rawPath": "/users/42",
"rawQueryString": "include=address&tag=a&tag=b",
"cookies": ["session=abc", "theme=dark"],
"headers": {
"content-type": "application/json", // lowercase in v2
"authorization": "Bearer eyJ..."
},
"queryStringParameters": { "include": "address", "tag": "a,b" }, // comma-joined
"pathParameters": { "id": "42" },
"requestContext": {
"accountId": "123456789012",
"apiId": "api-id",
"domainName": "id.execute-api.us-east-1.amazonaws.com",
"http": {
"method": "POST",
"path": "/users/42",
"protocol": "HTTP/1.1",
"sourceIp": "1.2.3.4",
"userAgent": "axios/1.6"
},
"requestId": "req-uuid",
"routeKey": "POST /users/{id}",
"stage": "$default",
"time": "25/Feb/2026:10:00:00 +0000",
"timeEpoch": 1740481200000
},
"body": "{"name":"Alice","email":"alice@example.com"}",
// body is still a string in v2 — JSON.parse() still required
"isBase64Encoded": false,
"stageVariables": {}
}
// ── S3 Event Notification ─────────────────────────────────────────────
{
"Records": [
{
"eventVersion": "2.1",
"eventSource": "aws:s3",
"awsRegion": "us-east-1",
"eventTime": "2026-02-25T10:00:00.000Z",
"eventName": "ObjectCreated:Put",
"s3": {
"s3SchemaVersion": "1.0",
"configurationId": "trigger-id",
"bucket": {
"name": "my-bucket",
"arn": "arn:aws:s3:::my-bucket"
},
"object": {
"key": "uploads/report.json",
"size": 4096,
"eTag": "abc123",
"versionId": "v1"
}
}
}
]
}
// S3 does NOT send file contents — only metadata.
// To read the file: use S3 SDK with bucket + key from event.Records[0].s3
// ── SQS Event ─────────────────────────────────────────────────────────
{
"Records": [
{
"messageId": "msg-uuid",
"receiptHandle": "AQEBwJ...",
"body": "{"orderId":"ORD-001","amount":99.99}",
// body is a string — JSON.parse(record.body) required
"attributes": {
"ApproximateReceiveCount": "1",
"SentTimestamp": "1740481200000",
"SenderId": "AIDAIENQZJOLO23YVJ4VO",
"ApproximateFirstReceiveTimestamp": "1740481200100"
},
"messageAttributes": {},
"md5OfBody": "abc123",
"eventSource": "aws:sqs",
"eventSourceARN": "arn:aws:sqs:us-east-1:123:my-queue",
"awsRegion": "us-east-1"
}
// up to 10 records per invocation (batch size configurable)
]
}
// ── SNS Event ─────────────────────────────────────────────────────────
{
"Records": [
{
"EventSource": "aws:sns",
"EventVersion": "1.0",
"EventSubscriptionArn": "arn:aws:sns:us-east-1:123:my-topic:sub-uuid",
"Sns": {
"Type": "Notification",
"MessageId": "msg-uuid",
"TopicArn": "arn:aws:sns:us-east-1:123:my-topic",
"Subject": "Order Placed",
"Message": "{"orderId":"ORD-001","amount":99.99}",
// Message is a string — JSON.parse(record.Sns.Message) required
"Timestamp": "2026-02-25T10:00:00.000Z",
"SignatureVersion": "1",
"Signature": "EXAMPLE==",
"MessageAttributes": {
"eventType": { "Type": "String", "Value": "order.placed" }
}
}
}
]
}
// ── EventBridge Event ─────────────────────────────────────────────────
{
"version": "0",
"id": "event-uuid",
"source": "com.myapp.orders",
"account": "123456789012",
"time": "2026-02-25T10:00:00Z",
"region": "us-east-1",
"resources": ["arn:aws:..."],
"detail-type": "Order Placed",
"detail": {
"orderId": "ORD-001",
"amount": 99.99
}
// detail is a parsed JSON object — no JSON.parse() needed
}The critical pattern: for API Gateway v1/v2, SQS, and SNS triggers, the user-supplied payload arrives as a JSON string nested inside a string field (body or Message) — always call JSON.parse() on it. For EventBridge, the detail field is already a parsed object. For S3 events, the payload is only metadata — use the SDK to fetch the actual object content. For more on validating the parsed payload, see our JSON data validation guide.
API Gateway Proxy Integration JSON
API Gateway proxy integration passes the entire HTTP request as a single JSON event to Lambda and expects a structured JSON response. The proxy model means API Gateway performs no transformation — it is the Lambda function's responsibility to interpret the raw event and produce a valid response object. The most common bugs stem from misunderstanding the string-vs-object boundary.
// ── TypeScript types for API Gateway events ───────────────────────────
import type {
APIGatewayProxyEvent, // v1 REST API
APIGatewayProxyEventV2, // v2 HTTP API
APIGatewayProxyResult, // v1 response
APIGatewayProxyResultV2, // v2 response
} from 'aws-lambda';
// ── Detect v1 vs v2 event ──────────────────────────────────────────────
function isV2Event(
event: APIGatewayProxyEvent | APIGatewayProxyEventV2
): event is APIGatewayProxyEventV2 {
return (event as APIGatewayProxyEventV2).version === '2.0';
}
// ── Parse event.body safely ───────────────────────────────────────────
function parseBody<T>(body: string | null): T | null {
if (!body) return null;
try {
return JSON.parse(body) as T;
} catch {
return null;
}
}
// ── v1 REST API handler ────────────────────────────────────────────────
export const handlerV1 = async (
event: APIGatewayProxyEvent
): Promise<APIGatewayProxyResult> => {
// pathParameters — object or null (never undefined in TypeScript type)
const userId = event.pathParameters?.id;
// queryStringParameters — object or null
const includeAddress = event.queryStringParameters?.include === 'address';
// multiValueQueryStringParameters — for repeated params: ?tag=a&tag=b
const tags = event.multiValueQueryStringParameters?.tag ?? [];
// headers — case-insensitive in HTTP but v1 preserves original case
const contentType = event.headers['Content-Type'] ?? event.headers['content-type'];
// body — always string, JSON.parse required
const body = parseBody<{ name: string; email: string }>(event.body);
if (!body) {
return {
statusCode: 400,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ error: 'Invalid JSON body' }),
};
}
return {
statusCode: 200,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId, name: body.name, tags }),
};
};
// ── v2 HTTP API handler ────────────────────────────────────────────────
export const handlerV2 = async (
event: APIGatewayProxyEventV2
): Promise<APIGatewayProxyResultV2> => {
// routeKey: "POST /users/{id}"
const method = event.requestContext.http.method; // v2: here, not top-level
// pathParameters — same as v1
const userId = event.pathParameters?.id;
// queryStringParameters — comma-joined multi-values in v2
// "tag=a&tag=b" becomes { tag: "a,b" }
const tagParam = event.queryStringParameters?.tag;
const tags = tagParam ? tagParam.split(',') : [];
// rawQueryString — original unparsed query string (for custom parsing)
const raw = event.rawQueryString;
// cookies — dedicated array in v2 (not in Cookie header)
const cookies = event.cookies ?? []; // ["session=abc", "theme=dark"]
// headers — lowercase in v2
const contentType = event.headers['content-type'];
// body — still a string in v2
const body = parseBody<{ name: string; email: string }>(event.body);
// isBase64Encoded — true if client sent binary body
if (event.isBase64Encoded && event.body) {
const raw = Buffer.from(event.body, 'base64');
// process raw binary...
}
return {
statusCode: 200,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId, name: body?.name, tags, cookies }),
};
};
// ── Stage variables and request context ───────────────────────────────
// Stage variables: set in API Gateway console, available in event
const dbHost = event.stageVariables?.dbHost ?? 'localhost';
// Request context fields useful for logging and tracing
const requestId = event.requestContext.requestId; // v1
const requestId2 = event.requestContext.requestId; // v2 — same field
const stage = event.requestContext.stage; // "prod", "$default" (v2)
const sourceIp = event.requestContext.identity?.sourceIp; // v1
const sourceIp2 = event.requestContext.http?.sourceIp; // v2The most important difference between v1 and v2: in v1, the HTTP method is at event.httpMethod; in v2, it is at event.requestContext.http.method. In v1, headers preserve the original casing sent by the client; in v2, all header names are lowercased. Multi-value query parameters in v1 use multiValueQueryStringParameters; in v2 they are comma-joined in queryStringParameters — use event.rawQueryString and a URL parser for precise handling. See our JSON API design guide for response structure best practices.
Lambda JSON Response Format
Lambda functions invoked through API Gateway or a Lambda Function URL must return a specific JSON response shape. Deviating from this shape — most commonly by forgetting to JSON.stringify() the body or omitting statusCode — results in a 502 Bad Gateway error from API Gateway. The response format is the same for v1 REST API and v2 HTTP API proxy integrations.
// ── Minimal valid response ─────────────────────────────────────────────
export const handler = async (): Promise<APIGatewayProxyResult> => {
return {
statusCode: 200, // required — integer HTTP status
headers: { // recommended — HTTP response headers
'Content-Type': 'application/json',
'X-Request-Id': 'req-uuid',
},
body: JSON.stringify({ message: 'ok' }), // required — MUST be a string
};
};
// ── Common mistake: returning an object for body ───────────────────────
// This causes API Gateway to return 502 Bad Gateway:
return {
statusCode: 200,
body: { message: 'ok' }, // ERROR: body must be a string, not an object
};
// Fix:
return {
statusCode: 200,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: 'ok' }), // body is JSON string
};
// ── Error response ─────────────────────────────────────────────────────
return {
statusCode: 400,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
error: 'VALIDATION_ERROR',
message: 'email field is required',
requestId: event.requestContext.requestId,
}),
};
// ── Response helpers ───────────────────────────────────────────────────
type JsonValue = Record<string, unknown> | unknown[];
function jsonResponse(
statusCode: number,
data: JsonValue,
extraHeaders: Record<string, string> = {}
): APIGatewayProxyResult {
return {
statusCode,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
...extraHeaders,
},
body: JSON.stringify(data),
};
}
// Usage:
return jsonResponse(200, { id: 1, name: 'Alice' });
return jsonResponse(404, { error: 'NOT_FOUND' });
return jsonResponse(201, { id: newId }, { Location: '/users/' + newId });
// ── Multi-value headers (e.g., Set-Cookie) ────────────────────────────
// Use multiValueHeaders instead of headers for repeated header names:
return {
statusCode: 200,
multiValueHeaders: {
'Set-Cookie': [
'session=abc; HttpOnly; Secure; SameSite=Strict',
'theme=dark; Max-Age=86400',
],
'Content-Type': ['application/json'],
},
body: JSON.stringify({ message: 'logged in' }),
};
// Note: cannot mix headers and multiValueHeaders for the same key —
// multiValueHeaders takes precedence when both are present.
// ── Binary response with isBase64Encoded ──────────────────────────────
import { readFileSync } from 'fs';
const imageBuffer = readFileSync('/tmp/chart.png');
return {
statusCode: 200,
headers: {
'Content-Type': 'image/png',
'Content-Length': String(imageBuffer.length),
},
body: imageBuffer.toString('base64'), // Base64-encode binary data
isBase64Encoded: true, // tell API Gateway to decode before sending
};
// ── CORS headers ───────────────────────────────────────────────────────
// For browser clients, include CORS headers in every response:
function corsResponse(statusCode: number, data: JsonValue): APIGatewayProxyResult {
return {
statusCode,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': 'https://app.example.com',
'Access-Control-Allow-Headers': 'Content-Type,Authorization',
'Access-Control-Allow-Methods': 'GET,POST,PUT,DELETE,OPTIONS',
},
body: JSON.stringify(data),
};
}
// Handle preflight OPTIONS requests:
if (event.httpMethod === 'OPTIONS') {
return corsResponse(204, {});
}For direct Lambda invocations (not through API Gateway), you can return any JSON-serializable value without the statusCode/body wrapper. For async invocations (S3, SNS, SQS triggers), the return value is completely ignored by AWS — Lambda Destinations or explicit SDK calls are required to route output. See our JSON API error handling guide for error response conventions.
Payload Size Limits and Chunking
Lambda enforces strict payload size limits that vary by invocation type. Exceeding these limits results in errors that are distinct from application-level errors — they are infrastructure-layer rejections. Understanding the limits and designing around them with chunking or delegation patterns prevents hard-to-debug production failures.
// ── Payload limits summary ────────────────────────────────────────────
// Synchronous (RequestResponse): 6 MB request + 6 MB response
// Asynchronous (Event type): 256 KB request (response ignored)
// SQS message body: 256 KB per message
// SQS batch to Lambda: up to 10 messages (~2.5 MB total)
// SNS message: 256 KB
// API Gateway request body: 10 MB (but Lambda's 6 MB applies first)
// EventBridge event detail: 256 KB
// ── Pattern 1: S3 staging for large synchronous payloads ──────────────
// Caller: upload large JSON to S3, pass presigned URL to Lambda
import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
const s3 = new S3Client({ region: 'us-east-1' });
// Caller side: upload payload, invoke Lambda with the URL
async function invokeLambdaWithLargePayload(largeData: object) {
const key = `payloads/${Date.now()}.json`;
await s3.send(new PutObjectCommand({
Bucket: 'my-staging-bucket',
Key: key,
Body: JSON.stringify(largeData),
ContentType: 'application/json',
}));
const url = await getSignedUrl(s3, new GetObjectCommand({
Bucket: 'my-staging-bucket',
Key: key,
}), { expiresIn: 300 }); // 5-minute expiry
// Lambda invocation payload is tiny — just the S3 URL
return lambdaClient.send(new InvokeCommand({
FunctionName: 'processor',
Payload: JSON.stringify({ s3Url: url }),
}));
}
// Lambda side: download from S3 URL
export const handler = async (event: { s3Url?: string; data?: object }) => {
let payload: object;
if (event.s3Url) {
// Large payload delivered via S3
const response = await fetch(event.s3Url);
if (!response.ok) throw new Error(`S3 fetch failed: ${response.status}`);
payload = await response.json() as object;
} else if (event.data) {
// Small payload delivered directly
payload = event.data;
} else {
throw new Error('No payload provided');
}
return processPayload(payload);
};
// ── Pattern 2: Chunking large arrays for async processing ─────────────
// Instead of one large Lambda call, fan out to many small async calls
import { LambdaClient, InvokeCommand } from '@aws-sdk/client-lambda';
const lambda = new LambdaClient({ region: 'us-east-1' });
async function processLargeArray(items: object[]) {
const CHUNK_SIZE = 100; // tune based on item size to stay under 256 KB per async call
const chunks: object[][] = [];
for (let i = 0; i < items.length; i += CHUNK_SIZE) {
chunks.push(items.slice(i, i + CHUNK_SIZE));
}
// Fan out: invoke child Lambda asynchronously for each chunk
await Promise.all(chunks.map((chunk, index) =>
lambda.send(new InvokeCommand({
FunctionName: 'item-processor',
InvocationType: 'Event', // async — 256 KB limit
Payload: JSON.stringify({
chunk,
chunkIndex: index,
totalChunks: chunks.length,
}),
}))
));
return { chunksDispatched: chunks.length };
}
// ── Pattern 3: SQS chunking for message size limits ───────────────────
import { SQSClient, SendMessageCommand, SendMessageBatchCommand } from '@aws-sdk/client-sqs';
const sqs = new SQSClient({ region: 'us-east-1' });
// Each SQS message body must be under 256 KB
function estimateJsonBytes(obj: object): number {
return Buffer.byteLength(JSON.stringify(obj), 'utf8');
}
async function sendToSQS(record: object) {
const body = JSON.stringify(record);
const bytes = Buffer.byteLength(body, 'utf8');
if (bytes > 240 * 1024) {
// Too large for SQS — store in S3, send reference
const key = `records/${Date.now()}.json`;
await s3.send(new PutObjectCommand({
Bucket: 'my-staging-bucket', Key: key,
Body: body, ContentType: 'application/json',
}));
await sqs.send(new SendMessageCommand({
QueueUrl: process.env.QUEUE_URL!,
MessageBody: JSON.stringify({ s3Ref: key }),
}));
} else {
await sqs.send(new SendMessageCommand({
QueueUrl: process.env.QUEUE_URL!,
MessageBody: body,
}));
}
}The S3 staging pattern is the standard AWS solution for oversized Lambda payloads. For Step Functions state machine executions, note that the state input/output is also limited to 256 KB — use the same S3 reference pattern for large intermediate results. For SQS message batching, the SendMessageBatchCommand accepts up to 10 messages per call but the total batch size is also capped at 256 KB. Always measure serialized byte size, not character count, with Buffer.byteLength(json, 'utf8') since multi-byte Unicode characters are counted differently.
JSON Parsing in Lambda Handlers
Every Lambda function that processes JSON from external sources must validate the parsed structure — not just parse it. A malformed SQS message, a missing required field from an API client, or a schema mismatch from an upstream service can crash the handler if unhandled. The standard approach is to validate with Zod at the entry point and route errors appropriately depending on the trigger type.
import { z } from 'zod';
import type {
APIGatewayProxyEvent,
SQSEvent,
SQSBatchResponse,
} from 'aws-lambda';
// ── Define schemas at module level (NOT inside handler) ───────────────
// Defined once at import time — zero compilation cost per invocation
const CreateOrderSchema = z.object({
customerId: z.string().uuid(),
items: z.array(z.object({
productId: z.string(),
quantity: z.number().int().positive(),
unitPrice: z.number().positive(),
})).min(1),
shippingAddress: z.object({
street: z.string(),
city: z.string(),
country: z.string().length(2),
}),
metadata: z.record(z.string()).optional(),
});
type CreateOrder = z.infer<typeof CreateOrderSchema>;
// ── API Gateway handler with Zod validation ───────────────────────────
export const apiHandler = async (
event: APIGatewayProxyEvent
) => {
// Step 1: parse body string
let rawBody: unknown;
try {
rawBody = JSON.parse(event.body ?? '');
} catch {
return {
statusCode: 400,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ error: 'INVALID_JSON', message: 'Request body is not valid JSON' }),
};
}
// Step 2: validate structure with Zod
const result = CreateOrderSchema.safeParse(rawBody);
if (!result.success) {
return {
statusCode: 422,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
error: 'VALIDATION_ERROR',
issues: result.error.issues.map(i => ({
path: i.path.join('.'),
message: i.message,
})),
}),
};
}
const order: CreateOrder = result.data;
// order is fully typed and validated
const created = await createOrder(order);
return {
statusCode: 201,
headers: { 'Content-Type': 'application/json', Location: `/orders/${created.id}` },
body: JSON.stringify(created),
};
};
// ── SQS handler with partial batch failure handling ───────────────────
const SQSOrderSchema = z.object({
orderId: z.string(),
eventType: z.enum(['placed', 'cancelled', 'updated']),
payload: z.record(z.unknown()),
timestamp: z.string().datetime(),
});
export const sqsHandler = async (event: SQSEvent): Promise<SQSBatchResponse> => {
const failures: string[] = [];
await Promise.allSettled(
event.Records.map(async (record) => {
// Step 1: parse SQS message body string
let rawMessage: unknown;
try {
rawMessage = JSON.parse(record.body);
} catch {
console.error(`[DLQ-CANDIDATE] Malformed JSON in message ${record.messageId}`);
failures.push(record.messageId);
return; // will be retried up to maxReceiveCount, then sent to DLQ
}
// Step 2: validate with Zod
const parsed = SQSOrderSchema.safeParse(rawMessage);
if (!parsed.success) {
console.error(`[SCHEMA-ERROR] message=${record.messageId}`, parsed.error.issues);
failures.push(record.messageId);
return;
}
// Step 3: process validated message
try {
await processOrderEvent(parsed.data);
} catch (err) {
console.error(`[PROCESS-ERROR] message=${record.messageId}`, err);
failures.push(record.messageId);
}
})
);
// Return failed messageIds — only these will be retried
// Requires FunctionResponseTypes: ['ReportBatchItemFailures'] on event source mapping
return {
batchItemFailures: failures.map(id => ({ itemIdentifier: id })),
};
};
// ── Dead Letter Queue: handling poison-pill messages ──────────────────
// A poison-pill message: JSON that parses but fails validation, then retries
// endlessly. Pattern: detect by ApproximateReceiveCount, log and skip
export const sqsHandlerWithDLQCheck = async (event: SQSEvent): Promise<SQSBatchResponse> => {
const failures: string[] = [];
for (const record of event.Records) {
const receiveCount = parseInt(record.attributes.ApproximateReceiveCount);
// After 3 receives, treat as poison-pill — log context for debugging
if (receiveCount > 3) {
console.error('[POISON-PILL]', {
messageId: record.messageId,
receiveCount,
body: record.body.slice(0, 500), // truncate for log safety
});
// Do NOT push to failures — let it go to DLQ naturally after maxReceiveCount
continue;
}
let parsed: unknown;
try {
parsed = JSON.parse(record.body);
} catch {
failures.push(record.messageId);
continue;
}
const result = SQSOrderSchema.safeParse(parsed);
if (!result.success) {
failures.push(record.messageId);
continue;
}
await processOrderEvent(result.data).catch(() => {
failures.push(record.messageId);
});
}
return { batchItemFailures: failures.map(id => ({ itemIdentifier: id })) };
};Always define Zod schemas and AJV validators at module level — they are initialized once when the Lambda container starts and reused across warm invocations. The ReportBatchItemFailures feature (partial batch failure) is the most important SQS-specific pattern: without it, a single bad message causes the entire batch of up to 10 messages to retry, blocking the queue. For comprehensive JSON Schema validation with AJV and Zod, see the linked guide.
Lambda Destinations and JSON Event Routing
Lambda Destinations allow async invocations to route their result JSON to another service (EventBridge, SQS, SNS, or another Lambda) on success or failure — without polling or DLQs for output. Step Functions state machines pass JSON between states, with each state's output becoming the next state's input. Understanding the JSON schema of these routing events is essential for building reliable event-driven pipelines.
// ── Lambda Destinations: output event schema ──────────────────────────
// When Lambda Destination routes to EventBridge, the event.detail looks like:
{
"version": "1.0",
"timestamp": "2026-02-25T10:00:00.123Z",
"requestContext": {
"requestId": "req-uuid",
"functionArn": "arn:aws:lambda:us-east-1:123:function:my-func",
"condition": "Success", // or "RetriesExhausted"
"approximateInvokeCount": 1
},
"requestPayload": {
// original input event that triggered the Lambda
"orderId": "ORD-001",
"amount": 99.99
},
"responseContext": {
"statusCode": 200,
"executedVersion": "$LATEST",
"functionError": null
},
"responsePayload": {
// whatever your Lambda function returned
"processed": true,
"invoiceId": "INV-456"
}
}
// Destination consumer Lambda — receives the wrapped event via EventBridge
export const destinationConsumer = async (event: {
detail: {
requestContext: { condition: 'Success' | 'RetriesExhausted'; requestId: string };
requestPayload: { orderId: string; amount: number };
responsePayload: { processed: boolean; invoiceId: string } | null;
responseContext: { statusCode: number; functionError: string | null };
};
}) => {
const { condition, requestId } = event.detail.requestContext;
if (condition === 'RetriesExhausted') {
// Async invocation failed after all retries
const originalInput = event.detail.requestPayload;
console.error(`[DEST-FAILURE] requestId=${requestId}`, originalInput);
await alertOps(originalInput);
return;
}
const result = event.detail.responsePayload!;
await notifyDownstream(result.invoiceId);
};
// ── Step Functions: state machine JSON flow ────────────────────────────
// State machine definition (JSON) — each state receives and produces JSON
const stateMachineDefinition = {
Comment: 'Order processing pipeline',
StartAt: 'ValidateOrder',
States: {
ValidateOrder: {
Type: 'Task',
Resource: 'arn:aws:lambda:us-east-1:123:function:validate-order',
// input to this state: the original execution input
// output from this state: whatever validate-order returns
ResultPath: '$.validationResult', // merge result into state JSON
Next: 'ProcessPayment',
},
ProcessPayment: {
Type: 'Task',
Resource: 'arn:aws:lambda:us-east-1:123:function:process-payment',
// input: { ...original, validationResult: { valid: true, ... } }
InputPath: '$.payload', // pass only the payload field
ResultPath: '$.paymentResult',
Next: 'SendConfirmation',
},
SendConfirmation: {
Type: 'Task',
Resource: 'arn:aws:lambda:us-east-1:123:function:send-email',
InputPath: '$.paymentResult', // pass only payment result
End: true,
},
},
};
// ── Step Functions Lambda handler pattern ─────────────────────────────
// Lambda in a Step Functions state receives the state input directly
// (no event.body wrapping — the event IS the state input)
export const validateOrderHandler = async (
event: { orderId: string; customerId: string; items: unknown[] }
) => {
// event is the Step Functions state input — already parsed, no JSON.parse needed
const { orderId, customerId, items } = event;
if (items.length === 0) {
throw new Error('Order must have at least one item');
// Step Functions catches this and routes to Catch or Retry states
}
return {
valid: true,
orderId,
validatedAt: new Date().toISOString(),
// This object is merged into the state JSON at $.validationResult
};
};
// ── EventBridge event schema registry ────────────────────────────────
// Register your event schemas in EventBridge Schema Registry for type generation
// Schema example for a custom event:
const orderPlacedSchema = {
openapi: '3.0.0',
info: { title: 'OrderPlaced', version: '1.0.0' },
paths: {},
components: {
schemas: {
AWSEvent: {
type: 'object',
properties: {
'detail-type': { type: 'string', const: 'Order Placed' },
source: { type: 'string', const: 'com.myapp.orders' },
detail: {
type: 'object',
required: ['orderId', 'amount'],
properties: {
orderId: { type: 'string' },
amount: { type: 'number' },
currency: { type: 'string', default: 'USD' },
},
},
},
},
},
},
};Lambda Destinations with EventBridge are more powerful than DLQs because they carry both the original input and the function's output (or error details) in a structured JSON schema — unlike SQS DLQs which only carry the original message. For Step Functions, use ResultPath to merge Lambda output into the state JSON rather than replacing the entire state — this preserves context across states. For event-driven JSON architecture patterns, see the linked guide.
Cold Start JSON Optimization
Lambda cold starts occur when a new container is initialized for a function. All module-level code runs during cold start — including JSON schema compilation, SDK client initialization, and module imports. Careless placement of expensive JSON operations inside handler functions can add tens of milliseconds to every invocation, not just cold starts.
import Ajv from 'ajv';
import addFormats from 'ajv-formats';
import { z } from 'zod';
// ── Module-level initialization: runs ONCE on cold start ──────────────
// AJV: compile schemas here, not inside handler
const ajv = new Ajv({ allErrors: true, strict: true });
addFormats(ajv);
// ajv.compile() is expensive (~50ms for a 10KB schema)
// Running it here: paid once at cold start
// Running it inside handler: paid on EVERY invocation
const validateOrder = ajv.compile({
type: 'object',
required: ['customerId', 'items'],
properties: {
customerId: { type: 'string', format: 'uuid' },
items: {
type: 'array',
minItems: 1,
items: {
type: 'object',
required: ['productId', 'quantity'],
properties: {
productId: { type: 'string' },
quantity: { type: 'integer', minimum: 1 },
unitPrice: { type: 'number', minimum: 0 },
},
additionalProperties: false,
},
},
},
additionalProperties: false,
});
// Zod schemas are lightweight — definition is free, .parse() is fast
const OrderSchema = z.object({
customerId: z.string().uuid(),
items: z.array(z.object({
productId: z.string(),
quantity: z.number().int().min(1),
})).min(1),
});
// SDK clients: initialize at module level — reused across warm invocations
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
const dynamoDb = DynamoDBDocumentClient.from(
new DynamoDBClient({ region: process.env.AWS_REGION }),
{ marshallOptions: { removeUndefinedValues: true } }
);
// ── Handler: runs on every invocation ─────────────────────────────────
export const handler = async (event: APIGatewayProxyEvent) => {
// JSON.parse: fast — negligible overhead for typical payloads
let body: unknown;
try {
body = JSON.parse(event.body ?? '');
} catch {
return { statusCode: 400, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ error: 'INVALID_JSON' }) };
}
// validate: uses pre-compiled AJV validator — fast on warm invocations
if (!validateOrder(body)) {
return { statusCode: 422, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ error: 'VALIDATION_ERROR', details: validateOrder.errors }) };
}
// process...
};
// ── Bundle size impact on cold start ─────────────────────────────────
// Cold start time correlates with bundle size (I/O to load the file).
// Typical impact: ~1ms per 1MB of zipped bundle size.
// AJV alone: ~180KB zipped. Zod: ~60KB. lodash: ~70KB.
//
// To minimize:
// 1. Use esbuild for bundling — tree-shakes unused exports
// 2. Use @aws-sdk/client-* (modular) not the full aws-sdk v2
// 3. Prefer Zod over AJV when schema is simple (smaller bundle)
// 4. Use Lambda layers for shared dependencies (avoids re-uploading)
// ── esbuild config for optimal Lambda bundles ─────────────────────────
// esbuild.config.mjs:
// import { build } from 'esbuild';
// await build({
// entryPoints: ['src/handler.ts'],
// bundle: true,
// minify: true,
// platform: 'node',
// target: 'node20',
// external: ['@aws-sdk/*'], // exclude AWS SDK — pre-installed in Lambda runtime
// outfile: 'dist/handler.js',
// });
// ── Provisioned Concurrency: eliminate cold starts ─────────────────────
// For latency-critical functions, use Provisioned Concurrency via AWS console
// or CDK. The module-level code still runs once at provision time, then the
// warm container is kept alive. JSON schema compilation cost is negligible.
// ── JSON.parse performance for large payloads ─────────────────────────
// Benchmark: JSON.parse on various payload sizes (Node.js 20, M1 Mac)
// 1 KB: ~0.01 ms
// 10 KB: ~0.1 ms
// 100 KB: ~1 ms
// 1 MB: ~10 ms
// 5 MB: ~50 ms (near the sync limit)
//
// For near-limit payloads (>1 MB), JSON parsing itself becomes measurable.
// In these cases, consider streaming JSON parsers (e.g., jsonparse, clarinet)
// or switching to a binary format (MessagePack, Protobuf) for the payload.The single most impactful optimization is moving all schema compilation to module level. The second most impactful is minimizing bundle size — the AWS SDK v3's modular packages (@aws-sdk/client-dynamodb instead of the full aws-sdk v2) reduce zipped bundle size by 80%+ for functions that only use one or two services. For cold-start-sensitive functions, Provisioned Concurrency eliminates the problem entirely at the cost of idle capacity charges. For JSON performance benchmarking methodology, see our JSON performance guide.
Key Terms
- Lambda Proxy Integration
- An API Gateway integration mode where the entire HTTP request is serialized into a single JSON event and passed to the Lambda function, and the Lambda function's JSON response is used directly as the HTTP response — with no request or response transformation performed by API Gateway. Contrast with non-proxy (custom) integration, where API Gateway applies mapping templates to transform requests and responses. Proxy integration is the standard choice for Lambda-based APIs because it simplifies development (no mapping templates) and gives the function full control over the response. The tradeoff: the function must understand the proxy event JSON schema and produce a valid proxy response JSON — including
statusCode,headers, and a stringbodyfield. - API Gateway Proxy Event
- The JSON object that API Gateway sends to a Lambda function in proxy integration mode. For REST API (v1), the event includes
httpMethod,path,pathParameters,queryStringParameters,multiValueQueryStringParameters,headers,multiValueHeaders,body(string),isBase64Encoded,requestContext, andstageVariables. For HTTP API (v2), the event usesversion: "2.0",routeKey,rawPath,rawQueryString,cookies(array),headers(lowercased),requestContext.http(with method, path, sourceIp),body(string), andisBase64Encoded. Both versions require the function to callJSON.parse(event.body)to access the request body as an object. - Cold Start
- The initialization phase of a Lambda function's execution lifecycle that occurs when a new container is provisioned to handle a request. During a cold start, Lambda provisions the execution environment, downloads the function package, initializes the runtime (Node.js, Python, etc.), and runs all module-level code (imports, SDK client initialization, JSON schema compilation). Cold start duration typically ranges from 100 ms to 1000 ms depending on runtime, package size, and module initialization work. Subsequent invocations on the same warm container skip all initialization and run only the handler function. Warm invocations typically execute in 1–50 ms for lightweight handlers. JSON schema compilation at module level is a one-time cold start cost; compilation inside the handler is paid on every invocation.
- Payload Limit
- The maximum size of the JSON data that can be sent to or returned from a Lambda function in a single invocation. For synchronous invocations (RequestResponse), both the request and response payloads are limited to 6 MB each. For asynchronous invocations (Event type — used by S3, SNS, EventBridge, and async SDK calls), the request payload is limited to 256 KB and the response is ignored. SQS individual message bodies are limited to 256 KB. When payloads exceed these limits, AWS returns a
RequestEntityTooLargeExceptionfor synchronous calls or silently drops the invocation for async calls. The standard mitigation pattern uses Amazon S3 as a staging area: the large payload is stored in S3 and a small JSON reference (presigned URL or S3 key) is passed to Lambda. - Dead Letter Queue (DLQ)
- An SQS queue or SNS topic configured to receive messages that a Lambda function failed to process after all retry attempts. For SQS triggers, the DLQ is configured on the SQS queue itself (not on Lambda) and messages are moved there after the queue's
maxReceiveCountis exceeded. For async Lambda invocations (S3, SNS, EventBridge), a DLQ can be configured directly on the Lambda function — failed events are sent there after 2 retry attempts. DLQ messages contain the original payload but not the function's error details. Lambda Destinations (newer feature) are more powerful than DLQs for async invocations: they carry both the original input and the error context in a structured JSON schema, making debugging significantly easier. Poison-pill messages — messages that always cause the function to fail, often due to malformed JSON or schema violations — accumulate in DLQs and require manual inspection and reprocessing or deletion. - EventBridge
- An AWS serverless event bus service that routes JSON events between AWS services and custom applications based on rules that match event fields. EventBridge events always have a structured JSON envelope:
source(identifies the producer),detail-type(event type string),detail(arbitrary JSON object — the actual payload),id,version,account,time,region, andresources. Thedetailfield arrives in Lambda as a pre-parsed JavaScript object — unlike SQS and SNS, noJSON.parse()call is needed. EventBridge's Schema Registry can infer and store the JSON Schema for your custom events, enabling automatic TypeScript type generation for event consumers. Event size limit: 256 KB per event for the entire envelope including thedetailobject. - isBase64Encoded
- A boolean field in both the Lambda request event and response object that signals binary content encoded as Base64. In the request event (API Gateway proxy and Lambda Function URL),
isBase64Encoded: truemeans thebodystring contains Base64-encoded binary data — decode withBuffer.from(event.body, 'base64')before processing. In the Lambda response object, settingisBase64Encoded: truetells API Gateway (or the Function URL) to decode thebodystring from Base64 before sending the raw binary bytes to the client — used for returning images, PDFs, or other binary files. For API Gateway v1 REST API, binary pass-through also requires configuring "Binary Media Types" in the API settings (e.g.,image/*,application/octet-stream). For API Gateway v2 HTTP API and Lambda Function URLs, binary handling is automatic based on theisBase64Encodedflag alone. - Module-Level Initialization
- Code that runs at the top level of a JavaScript/TypeScript module — outside any function — that executes once when the Lambda container initializes (during cold start) and is then cached for all subsequent warm invocations. Examples include: importing modules with
import, creating SDK clients (new DynamoDBClient()), compiling JSON schemas (ajv.compile(schema)), defining Zod schemas (const Schema = z.object({...})), and reading environment variables (process.env.TABLE_NAME). Module-level initialization is the correct place for any expensive one-time setup because the cost is amortized across all warm invocations. Code inside the handler function runs on every invocation — placing schema compilation, SDK client construction, or connection pool initialization inside the handler multiplies their cost by invocation count.
FAQ
What does the AWS Lambda JSON event look like for an API Gateway request?
For API Gateway v1 REST API (proxy integration), the event includes httpMethod, path, pathParameters, queryStringParameters, headers, body (always a string — call JSON.parse(event.body)), isBase64Encoded, and requestContext with stage, requestId, and identity. For API Gateway v2 HTTP API (payload format 2.0), the shape differs: version: "2.0", routeKey, rawPath, rawQueryString, cookies (array), headers in lowercase, requestContext.http (with method and sourceIp), body (still a string), and isBase64Encoded. The critical difference: v1 has httpMethod at the top level; v2 has the method at requestContext.http.method. v1 preserves header casing; v2 lowercases all headers. v1 has multiValueQueryStringParameters for repeated params; v2 comma-joins them. Both versions always deliver the request body as a JSON string requiring manual parsing — neither version auto-parses it. Detect the format with event.version === '2.0'.
How do I return a JSON response from a Lambda function?
For functions behind API Gateway or a Lambda Function URL, return an object with three fields: statusCode (integer, e.g. 200), headers (object of string key-value pairs, include 'Content-Type': 'application/json'), and body (a string — you must call JSON.stringify() on your response data). Example: {statusCode: 200, headers: {'Content-Type': 'application/json'}, body: JSON.stringify({id: 1})}. Returning an object (not a string) for body causes API Gateway to return a 502 error. For multiple Set-Cookie headers, use multiValueHeaders instead of headers. For binary responses (images, PDFs), set isBase64Encoded: true and encode the binary body with buffer.toString('base64'). For error responses, use the appropriate HTTP status code (400 for validation errors, 404 for not found, 500 for server errors) and include structured error details in the JSON body. For direct SDK invocations (not through API Gateway), return any JSON-serializable value without the wrapper.
What are the JSON payload size limits for AWS Lambda?
Synchronous invocations (API Gateway, Lambda Function URL, direct SDK invoke): 6 MB request payload and 6 MB response payload. Asynchronous invocations (S3 event notifications, SNS, EventBridge rules, async SDK invoke): 256 KB request — response is ignored. SQS individual message bodies: 256 KB per message; Lambda receives up to 10 messages per batch invocation. SNS message: 256 KB. EventBridge event (full envelope including detail): 256 KB. When payloads exceed the 6 MB sync limit, use S3 as a staging layer: upload the large JSON to S3, generate a presigned URL, and pass only the URL (tiny JSON) to Lambda — the function downloads from S3 directly. For async use cases exceeding 256 KB, apply the same S3 reference pattern. Measure serialized size in bytes with Buffer.byteLength(JSON.stringify(payload), 'utf8') — character count underestimates multi-byte Unicode characters.
How do I parse and validate JSON from SQS messages in a Lambda function?
When SQS triggers Lambda, event.Records is an array of up to 10 SQS records. Each record's body field is a string — call JSON.parse(record.body) for each record. Wrap in try/catch to handle malformed JSON (a SyntaxError kills the invocation if unhandled). After parsing, validate with Zod (Schema.safeParse(parsed)) or AJV. For failed records, collect their messageId and return {batchItemFailures: [{itemIdentifier: messageId}]} — this requires FunctionResponseTypes: ['ReportBatchItemFailures'] on the SQS event source mapping. Without this setting, any failure causes the entire batch to retry. Messages that fail repeatedly (check record.attributes.ApproximateReceiveCount) are poison-pill candidates — let them exhaust the maxReceiveCount and route to the Dead Letter Queue. Always define Zod schemas at module level to avoid re-construction on every invocation.
How do I handle binary data in Lambda JSON responses (isBase64Encoded)?
To return binary data through API Gateway or a Lambda Function URL, set isBase64Encoded: true in the response object, Base64-encode the binary content with buffer.toString('base64'), set the appropriate Content-Type header (e.g., image/png), and return the encoded string as the body field. API Gateway (or the Function URL) decodes it before sending raw bytes to the client. For incoming binary requests, check event.isBase64Encoded === true and decode with Buffer.from(event.body, 'base64') before processing. For API Gateway v1 REST API, also configure "Binary Media Types" in the API settings to allow binary pass-through for specific content types (e.g., image/*). For v2 HTTP API and Lambda Function URLs, binary handling is automatic — isBase64Encoded is set correctly based on the request content. The body field is always a string in both directions — Base64 is the mechanism for conveying binary data through that string field.
How do I optimize JSON schema validation in Lambda to minimize cold start time?
The primary rule: compile JSON schemas at module level, not inside the handler. AJV compiling a 10 KB schema takes approximately 50 ms — inside the handler, this cost is paid on every invocation, not just cold starts. At module level, the cost is paid once when the container initializes. With AJV: const validate = ajv.compile(schema); at the top of the file. With Zod: const Schema = z.object({...}); — definition is fast; the expensive work is in .parse() which Zod handles efficiently. Beyond schema compilation: use the modular AWS SDK v3 (@aws-sdk/client-*) instead of the full v2 aws-sdk to reduce bundle size; bundle with esbuild (--bundle --minify --external:@aws-sdk/*) to tree-shake unused code; prefer Zod over AJV for simple schemas (Zod zipped: ~60 KB vs AJV: ~180 KB). For functions with strict latency SLAs, use Provisioned Concurrency to eliminate cold starts entirely — module initialization runs once at provision time.
What is the difference between API Gateway v1 and v2 proxy event JSON formats?
API Gateway v1 (REST API, payload format 1.0) event: httpMethod at top level, path, resource, pathParameters, queryStringParameters, multiValueQueryStringParameters (separate field for repeated params), headers (original casing), multiValueHeaders (separate field for repeated headers), requestContext with identity.sourceIp and identity.userAgent, body (string), isBase64Encoded. API Gateway v2 (HTTP API, payload format 2.0): version: "2.0", routeKey (e.g., "POST /users/{id}"), rawPath, rawQueryString, cookies (dedicated string array), headers (all lowercased), queryStringParameters (multi-values comma-joined — no separate multiValueQueryStringParameters), requestContext.http.method (not at top level), requestContext.timeEpoch, body (string), isBase64Encoded. Key practical differences: HTTP method location, header casing, multi-value parameter handling, and cookies. Detect version with event.version === '2.0'. Body is always a string in both versions.
Further reading and primary sources
- AWS Lambda Developer Guide: Event Source Mappings — Official reference for SQS, Kinesis, DynamoDB, and other event source trigger JSON formats
- API Gateway Payload Format Version — AWS documentation comparing v1 and v2 payload format differences for Lambda proxy integration
- AWS Lambda Quotas and Limits — Official payload size limits: 6 MB sync, 256 KB async, and all other Lambda service quotas
- Lambda Destinations — How to route async Lambda results and failures to EventBridge, SQS, SNS, or another Lambda
- AJV Documentation: Getting Started — AJV JSON Schema validator — compiling schemas, module-level initialization, and performance best practices