JSON Serverless Functions: Lambda, Vercel Edge & Structured Logging
Last updated:
Serverless functions receive and return JSON — AWS Lambda wraps every invocation in a JSON event object, returns a JSON response, and logs everything as JSON to CloudWatch for searchable structured logging. Cold starts add 100–500 ms to the first invocation — minimize by keeping JSON payload size under 6 MB (Lambda limit), avoiding large JSON imports at module level, and using provisioned concurrency for latency-critical functions. Vercel Edge runtime has a 4 MB response size limit and a 25 MB request body limit. This guide covers Lambda JSON event/context shapes, API Gateway proxy event structure, Vercel Edge and Node.js runtime JSON handling, cold start optimization, structured JSON logging to CloudWatch, and TypeScript types for Lambda handlers. Every pattern includes TypeScript types.
AWS Lambda JSON Event and Context Object Structure
Every Lambda invocation receives two arguments: the event object (the trigger-specific JSON payload) and the contextobject (invocation metadata). The event shape differs by trigger — API Gateway, SQS, SNS, EventBridge, and S3 each produce a distinct structure. The context object is consistent across all triggers and contains the AWS request ID, function name, remaining time, and log group name. Understanding both shapes is prerequisite to writing correct Lambda handlers.
// ── Lambda handler signature ─────────────────────────────────────
import type { Handler, Context } from 'aws-lambda';
// Generic handler: event type depends on trigger
export const handler: Handler = async (event, context: Context) => {
// context fields available for every trigger:
console.log(context.functionName); // "my-function"
console.log(context.awsRequestId); // "a1b2c3d4-..." — use as correlation ID
console.log(context.getRemainingTimeInMillis()); // ms until timeout
console.log(context.logGroupName); // "/aws/lambda/my-function"
console.log(context.memoryLimitInMB); // "512"
};
// ── API Gateway v1 (REST API) proxy event shape ───────────────────
import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
export const apiV1Handler = async (
event: APIGatewayProxyEvent
): Promise<APIGatewayProxyResult> => {
// event shape:
// {
// httpMethod: "POST",
// path: "/users/42",
// resource: "/users/{id}",
// headers: { "Content-Type": "application/json", "Authorization": "Bearer ..." },
// multiValueHeaders: { "Accept": ["application/json"] },
// queryStringParameters: { "page": "1" } | null,
// multiValueQueryStringParameters: { "tag": ["a", "b"] } | null,
// pathParameters: { "id": "42" } | null,
// body: '{"name":"Alice"}', // ← always a STRING
// isBase64Encoded: false,
// requestContext: {
// accountId: "123456789012",
// stage: "prod",
// requestId: "abc123",
// identity: { sourceIp: "1.2.3.4" },
// },
// stageVariables: null,
// }
return {
statusCode: 200,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ok: true }),
};
};
// ── API Gateway v2 (HTTP API) proxy event — simpler shape ─────────
import type { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from 'aws-lambda';
export const apiV2Handler = async (
event: APIGatewayProxyEventV2
): Promise<APIGatewayProxyResultV2> => {
// v2 event shape:
// {
// version: "2.0",
// routeKey: "POST /users/{id}",
// rawPath: "/users/42",
// rawQueryString: "page=1",
// headers: { "content-type": "application/json" }, // lowercase
// queryStringParameters: { "page": "1" }, // no multiValue variant
// pathParameters: { "id": "42" },
// body: '{"name":"Alice"}', // still a STRING
// isBase64Encoded: false,
// requestContext: {
// http: { method: "POST", path: "/users/42", sourceIp: "1.2.3.4" },
// requestId: "abc123",
// stage: "$default",
// time: "12/Jan/2026:00:00:00 +0000",
// },
// }
const userId = event.pathParameters?.id;
const body = event.body ? JSON.parse(event.body) : null;
return {
statusCode: 200,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId, received: body }),
};
};API Gateway v2 (HTTP API) is cheaper and has lower latency than v1 (REST API) — use it for new projects unless you need REST API features like API keys, usage plans, or per-stage caching. The key difference in event shape is that v2 uses requestContext.http.method instead of event.httpMethod, and headers are lowercased. Install @types/aws-lambdaonce and use its named types for every trigger — the TypeScript compiler catches shape mismatches before deployment.
API Gateway Proxy Integration: Request and Response JSON
The API Gateway proxy integration contract has two critical rules that cause 502 errors when violated: the response body must be a string (call JSON.stringify() — returning an object causes Malformed Lambda proxy response), and the request body is always a string that you must parse. Build helper functions that enforce both rules consistently across all routes.
import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
// ── Response helpers — enforce JSON.stringify() on body ───────────
function jsonResponse(
statusCode: number,
data: unknown,
extraHeaders: Record<string, string> = {}
): APIGatewayProxyResult {
return {
statusCode,
headers: {
'Content-Type': 'application/json',
'X-Content-Type-Options': 'nosniff',
...extraHeaders,
},
body: JSON.stringify(data), // never forget this
};
}
const ok = (data: unknown) => jsonResponse(200, data);
const created = (data: unknown) => jsonResponse(201, data);
const badReq = (msg: string) => jsonResponse(400, { error: msg });
const notFound = (msg: string) => jsonResponse(404, { error: msg });
const serverErr = () => jsonResponse(500, { error: 'Internal server error' });
// ── Request body parsing with base64 support ──────────────────────
function parseBody<T>(event: APIGatewayProxyEvent): T | null {
if (!event.body) return null;
try {
const raw = event.isBase64Encoded
? Buffer.from(event.body, 'base64').toString('utf8')
: event.body;
return JSON.parse(raw) as T;
} catch {
return null;
}
}
// ── Full handler example: POST /users ────────────────────────────
interface CreateUserBody {
name: string;
email: string;
role?: 'admin' | 'user';
}
export const createUserHandler = async (
event: APIGatewayProxyEvent
): Promise<APIGatewayProxyResult> => {
// Parse and validate body
const body = parseBody<CreateUserBody>(event);
if (!body) return badReq('Invalid or missing JSON body');
if (!body.name) return badReq('name is required');
if (!body.email || !body.email.includes('@')) return badReq('valid email is required');
// Read path and query parameters (always strings)
const orgId = event.pathParameters?.orgId;
const dryRun = event.queryStringParameters?.dryRun === 'true';
try {
const user = await createUser({ ...body, orgId });
return created({ user, dryRun });
} catch (err) {
console.error(JSON.stringify({ level: 'ERROR', err: String(err) }));
return serverErr();
}
};
// ── WRONG: these all cause 502 Bad Gateway ─────────────────────────
// return { statusCode: 200, body: { users: [] } }; // body is object
// return { statusCode: '200', body: JSON.stringify({}) }; // statusCode string
// return { statusCode: 200 }; // body missing
// return JSON.stringify({ statusCode: 200, ... }); // return is string
// ── 204 No Content — empty string body ──────────────────────────
const noContent = (): APIGatewayProxyResult => ({
statusCode: 204,
headers: {},
body: '', // empty string, not undefined
});A jsonResponse() helper is not optional — ad-hoc response construction in every route guarantees that someone will eventually forget JSON.stringify() and ship a 502. The helper also ensures a consistent error envelope shape, which client SDKs can rely on for error handling. When debugging 502 errors, check CloudWatch Logs under /aws/lambda/<function-name> for the exact message — Malformed Lambda proxy response confirms the body-type issue.
Vercel Edge and Node.js Runtime JSON Handling
Vercel serverless functions use the Web Fetch API — not Express. Handlers receive a Request object and must return a Response. The Edge runtime (V8 isolates on Vercel's global network) has near-zero cold starts and a 4 MB response limit; the Node.js runtime supports the full Node.js API but has higher cold start latency. Both runtimes use the same Response.json() API.
// app/api/users/route.ts (Next.js App Router — Node.js runtime by default)
import { NextRequest, NextResponse } from 'next/server';
// ── GET: return JSON ─────────────────────────────────────────────
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const page = parseInt(searchParams.get('page') ?? '1', 10);
const limit = parseInt(searchParams.get('limit') ?? '20', 10);
const users = await fetchUsers({ page, limit });
// Response.json() — sets Content-Type: application/json automatically
return Response.json({ users, page, limit });
}
// ── POST: parse body and return JSON ─────────────────────────────
export async function POST(request: NextRequest) {
let body: { name: string; email: string };
try {
body = await request.json(); // throws on invalid JSON or empty body
} catch {
return Response.json({ error: 'Invalid JSON body' }, { status: 400 });
}
if (!body.name || !body.email) {
return Response.json({ error: 'name and email are required' }, { status: 400 });
}
const user = await createUser(body);
return Response.json({ user }, { status: 201 });
}
// ── Edge runtime: add export const runtime = 'edge' ──────────────
// app/api/fast/route.ts
export const runtime = 'edge'; // opt into Edge runtime
export async function GET(request: Request) {
// No Buffer, no fs, no net — Web APIs only
// 4 MB response limit (vs no explicit limit on Node.js runtime)
// 25 MB request body limit
const data = await fetchFromOrigin();
return Response.json({ data, runtime: 'edge' });
}
// ── Edge constraint: no Buffer — use TextEncoder/TextDecoder ─────
// BAD: Buffer is not available in Edge runtime
// const decoded = Buffer.from(base64str, 'base64').toString('utf8');
// GOOD: Web APIs work in both Edge and Node.js
function base64ToUtf8(base64: string): string {
const binary = atob(base64);
const bytes = Uint8Array.from(binary, c => c.charCodeAt(0));
return new TextDecoder().decode(bytes);
}
// ── Custom headers and cache control ─────────────────────────────
export async function GET_CACHED() {
const data = await expensiveQuery();
return NextResponse.json(data, {
status: 200,
headers: {
'Cache-Control': 's-maxage=60, stale-while-revalidate=300',
'X-Request-Id': crypto.randomUUID(),
},
});
}
// ── Streaming JSON response (Next.js + Node.js runtime) ──────────
export async function GET_STREAM() {
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
controller.enqueue(encoder.encode('['));
let first = true;
for await (const record of fetchLargeDataset()) {
if (!first) controller.enqueue(encoder.encode(','));
controller.enqueue(encoder.encode(JSON.stringify(record)));
first = false;
}
controller.enqueue(encoder.encode(']'));
controller.close();
},
});
return new Response(stream, {
headers: { 'Content-Type': 'application/json' },
});
}The Edge runtime does not have Buffer — use atob() and TextDecoder for base64 decoding instead. The 4 MB response limit on Edge is strict; for larger JSON responses, either paginate, stream from origin using ReadableStream, or serve from Vercel Blob Storage. See our guide on JSON caching for Cache-Control patterns that reduce the number of origin fetches on Edge.
Cold Start Optimization for JSON-Heavy Lambda Functions
Cold start latency is dominated by module initialization, not per-request JSON parsing. JSON-heavy functions pay an extra cost when they import large schema files, initialize validation libraries, or parse config JSON on every invocation. Three categories of optimization cover most real-world cold start issues: move work to module scope, shrink the deployment bundle, and use pre-warming for latency-sensitive endpoints.
// ── Category 1: Module-scope initialization ──────────────────────
// BAD: JSON.parse() runs on every request
export const handler = async (event) => {
const config = JSON.parse(process.env.APP_CONFIG ?? '{}'); // per-request
const schema = await import('./schema.json'); // per-request
// ...
};
// GOOD: runs once per Lambda instance (warm invocation reuses these)
const config = JSON.parse(process.env.APP_CONFIG ?? '{}'); // one-time
import schema from './schema.json'; // one-time (top-level)
export const handler = async (event) => {
// config and schema are ready — no initialization cost
};
// ── V8 JSON.parse() faster than object literal for large data ────
// BAD: parsed by V8 as JS syntax (slower for 500+ key objects)
const COUNTRY_CODES = { US: 'United States', CA: 'Canada', GB: 'United Kingdom' /* ... 200 more */ };
// GOOD: V8 uses a specialized JSON parser path (measurably faster at scale)
const COUNTRY_CODES = JSON.parse('{"US":"United States","CA":"Canada","GB":"United Kingdom"}');
// ── Category 2: Bundle size reduction ────────────────────────────
// Use esbuild to bundle Lambda to a single file:
// esbuild src/handler.ts --bundle --platform=node --target=node20 // --external:@aws-sdk --outfile=dist/handler.js
// Import only the SDK clients you use (v3 modular)
// BAD: entire AWS SDK (50+ MB unzipped)
import AWS from 'aws-sdk';
// GOOD: only the DynamoDB client (~2 MB)
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, GetCommand } from '@aws-sdk/lib-dynamodb';
const dynamo = DynamoDBDocumentClient.from(new DynamoDBClient({})); // module scope
// ── Category 3: Provisioned Concurrency ──────────────────────────
// SAM template: pre-warm 5 instances — no cold starts for first 5 concurrent requests
// Resources:
// MyFunction:
// Type: AWS::Serverless::Function
// Properties:
// AutoPublishAlias: live
// ProvisionedConcurrencyConfig:
// ProvisionedConcurrentExecutions: 5
// ── Lazy-load large JSON schemas (only when route is hit) ─────────
let zodSchema: import('zod').ZodObject<any> | null = null;
async function getSchema() {
if (!zodSchema) {
const { z } = await import('zod');
zodSchema = z.object({
name: z.string().min(1).max(255),
email: z.string().email(),
role: z.enum(['admin', 'user']).optional(),
});
}
return zodSchema;
}
// ── Measure cold start: CloudWatch Logs Insights query ────────────
// filter @type = "REPORT"
// | stats
// avg(@initDuration) as avgInitMs,
// max(@initDuration) as maxInitMs,
// count(@initDuration) as coldStarts
// by bin(1h)
// (initDuration only appears for cold starts, not warm invocations)The V8 JSON parser tip — wrapping large static objects in JSON.parse()— is a documented V8 optimization: the engine parses JSON strings with a dedicated fast path that skips the full JavaScript AST construction. Benchmarks show 1.5–2x faster parsing for objects with hundreds of keys. Combine this with module-scope placement and the static data is both parsed fast and cached for the lifetime of the instance.
Structured JSON Logging in Serverless (CloudWatch, Datadog)
Structured logging means each log line is a complete JSON object with consistent fields — level, message, requestId, and any business context. CloudWatch Logs Insights queries fields from JSON log lines directly using the fields and filter syntax. Unstructured console.log("user created", userId) is a string that Logs Insights cannot query by field. See our JSON logging guide for a deeper treatment of log schema design.
import type { APIGatewayProxyEvent, APIGatewayProxyResult, Context } from 'aws-lambda';
// ── Structured logger factory ────────────────────────────────────
function makeLogger(context: Context, event: APIGatewayProxyEvent) {
const base = {
requestId: context.awsRequestId,
functionName: context.functionName,
path: event.path,
method: event.httpMethod,
};
return {
info: (msg: string, extra?: object) =>
console.log(JSON.stringify({ level: 'INFO', message: msg, ...base, ...extra, ts: new Date().toISOString() })),
warn: (msg: string, extra?: object) =>
console.warn(JSON.stringify({ level: 'WARN', message: msg, ...base, ...extra, ts: new Date().toISOString() })),
error: (msg: string, extra?: object) =>
console.error(JSON.stringify({ level: 'ERROR', message: msg, ...base, ...extra, ts: new Date().toISOString() })),
};
}
export const handler = async (
event: APIGatewayProxyEvent,
context: Context
): Promise<APIGatewayProxyResult> => {
const log = makeLogger(context, event);
log.info('handler invoked', { userAgent: event.headers?.['User-Agent'] });
const body = event.body ? JSON.parse(event.body) : null;
if (!body?.userId) {
log.warn('missing userId in body');
return { statusCode: 400, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ error: 'userId required' }) };
}
try {
const result = await processRequest(body.userId);
log.info('request completed', { userId: body.userId, durationMs: result.durationMs });
return { statusCode: 200, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(result) };
} catch (err) {
log.error('unhandled error', { err: String(err), stack: (err as Error).stack });
return { statusCode: 500, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ error: 'Internal server error' }) };
}
};
// ── Node.js 20 native JSON logging (no JSON.stringify needed) ─────
// Set env var: AWS_LAMBDA_LOG_FORMAT=JSON
// Lambda runtime wraps console.log() output in a JSON envelope:
// { "timestamp": "...", "level": "INFO", "message": "...", "requestId": "..." }
// When enabled, write plain objects — the runtime serializes them:
console.log({ message: 'request received', userId: '42' });
// Lambda emits: {"level":"INFO","requestId":"abc","message":"{"userId":"42"}"}
// ── CloudWatch Logs Insights queries ─────────────────────────────
// Query 1: find all ERROR logs in the last hour
// fields @timestamp, level, message, requestId, err
// | filter level = "ERROR"
// | sort @timestamp desc
// | limit 100
// Query 2: p99 latency by path
// filter @type = "REPORT"
// | stats pct(@duration, 99) as p99, avg(@duration) as avg by bin(5m)
// Query 3: error rate per minute
// fields @timestamp, level
// | filter level = "ERROR"
// | stats count() as errors by bin(1m)
// ── Datadog: use dd-trace for structured spans ────────────────────
// npm install dd-trace
// Add DD_TRACE_ENABLED=true, DD_API_KEY env vars in Lambda config
import tracer from 'dd-trace';
tracer.init({ logInjection: true }); // injects trace_id into log output
// dd-trace adds trace_id and span_id to every console.log output,
// enabling correlation between CloudWatch Logs and Datadog APM tracesAlways include context.awsRequestId in every log line — it is the only field that links all log entries for a single invocation. Without it, debugging a production issue requires scrolling through interleaved log lines from concurrent invocations. The AWS_LAMBDA_LOG_FORMAT=JSON environment variable enables native structured logging in Node.js 20+ without any library, but only when writing plain objects to console.log()— strings are still logged as strings.
TypeScript Types for Lambda JSON Handlers
TypeScript types for Lambda handlers come from @types/aws-lambda (install with npm install --save-dev @types/aws-lambda). The package provides named types for every trigger: API Gateway v1/v2, SQS, SNS, EventBridge, S3, DynamoDB Streams, Cognito, and more. Typed handlers catch the most common bugs — object body, string statusCode, missing fields — at compile time before deployment. See our TypeScript JSON types guide for advanced patterns.
import type {
APIGatewayProxyEvent,
APIGatewayProxyResult,
APIGatewayProxyEventV2,
APIGatewayProxyResultV2,
SQSEvent,
SQSRecord,
SQSBatchResponse,
SNSEvent,
EventBridgeEvent,
S3Event,
Context,
} from 'aws-lambda';
// ── Typed body parsing helper ─────────────────────────────────────
function parseBody<T>(
event: Pick<APIGatewayProxyEvent, 'body' | 'isBase64Encoded'>
): T | null {
if (!event.body) return null;
try {
const raw = event.isBase64Encoded
? Buffer.from(event.body, 'base64').toString('utf8')
: event.body;
return JSON.parse(raw) as T;
} catch {
return null;
}
}
// ── Domain types ─────────────────────────────────────────────────
interface CreateOrderBody {
customerId: string;
items: Array<{ productId: string; quantity: number }>;
couponCode?: string;
}
interface OrderCreatedEvent {
orderId: string;
customerId: string;
totalCents: number;
}
// ── API Gateway v1 handler ────────────────────────────────────────
export const createOrderHandler = async (
event: APIGatewayProxyEvent
): Promise<APIGatewayProxyResult> => {
const body = parseBody<CreateOrderBody>(event);
if (!body?.customerId || !body?.items?.length) {
return { statusCode: 400, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ error: 'customerId and items required' }) };
}
const order = await createOrder(body);
return { statusCode: 201, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ order }) };
};
// ── SQS handler with typed message body ───────────────────────────
export const sqsHandler = async (event: SQSEvent): Promise<SQSBatchResponse> => {
const failures: string[] = [];
for (const record of event.Records) {
try {
const payload = JSON.parse(record.body) as OrderCreatedEvent;
await fulfillOrder(payload.orderId, payload.customerId);
} catch (err) {
console.error(JSON.stringify({ level: 'ERROR', messageId: record.messageId, err: String(err) }));
failures.push(record.messageId);
}
}
// Partial batch failure — only retry failed records
return { batchItemFailures: failures.map(id => ({ itemIdentifier: id })) };
};
// ── EventBridge handler with typed detail ─────────────────────────
type OrderPlacedDetail = { orderId: string; customerId: string; totalCents: number };
export const eventBridgeHandler = async (
event: EventBridgeEvent<'order.placed', OrderPlacedDetail>
): Promise<void> => {
const { orderId, customerId, totalCents } = event.detail;
await sendConfirmationEmail(customerId, orderId, totalCents);
};
// ── SNS handler (body is double-encoded JSON) ─────────────────────
export const snsHandler = async (event: SNSEvent): Promise<void> => {
for (const record of event.Records) {
// record.Sns.Message is a string — parse it
const payload = JSON.parse(record.Sns.Message) as OrderCreatedEvent;
await notifyWarehouse(payload.orderId);
}
};
// ── S3 event handler ─────────────────────────────────────────────
export const s3Handler = async (event: S3Event): Promise<void> => {
for (const record of event.Records) {
const bucket = record.s3.bucket.name;
const key = decodeURIComponent(record.s3.object.key.replace(/+/g, ' '));
await processUploadedJson(bucket, key);
}
};TypeScript's as T cast after JSON.parse() is a type assertion, not a runtime validation. For production code, validate the parsed body against a schema using Zod (schema.parse(parsed)) or a similar library before trusting field types. The typed generic parseBody<T> helper keeps the unsafe cast in one place — all call sites get the typed result without repeating the cast. See our JSON API design guide for request/response envelope patterns that complement TypeScript types.
SQS, SNS, and EventBridge JSON Event Schemas
SQS, SNS, and EventBridge each wrap your message JSON in a platform-specific envelope. Understanding the envelope structure is required to correctly parse the inner payload — a common mistake is treating the SQS record.body as if it were the inner application event directly, when it may be an SNS envelope if the SQS queue is subscribed to an SNS topic. EventBridge puts your payload in event.detail as a parsed object, not a string.
// ── SQS event structure ──────────────────────────────────────────
// event.Records[0] shape:
// {
// messageId: "abc-123",
// receiptHandle: "AQEBwJ...",
// body: '{"orderId":"o-1","customerId":"c-42"}', // ← your JSON as string
// attributes: {
// ApproximateReceiveCount: "1",
// SentTimestamp: "1736000000000",
// SenderId: "AROAI...",
// ApproximateFirstReceiveTimestamp: "1736000001000",
// },
// messageAttributes: {},
// md5OfBody: "d8e8...",
// eventSource: "aws:sqs",
// eventSourceARN: "arn:aws:sqs:us-east-1:123:my-queue",
// awsRegion: "us-east-1",
// }
// Direct SQS message — body is your JSON string
const payload = JSON.parse(record.body);
// ── SNS-to-SQS fanout — body is an SNS envelope ──────────────────
// When SQS subscribes to SNS, body is a JSON string of the SNS envelope:
// record.body = JSON.stringify({
// Type: "Notification",
// MessageId: "sns-message-id",
// TopicArn: "arn:aws:sns:...",
// Subject: "order.placed",
// Message: '{"orderId":"o-1"}', // ← your payload, double-encoded
// Timestamp: "2026-01-20T...",
// SignatureVersion: "1",
// Signature: "...",
// })
function parseSqsBody<T>(record: { body: string }, isSns = false): T {
const outer = JSON.parse(record.body);
if (isSns) {
return JSON.parse(outer.Message) as T; // unwrap SNS envelope
}
return outer as T;
}
// ── EventBridge event structure ───────────────────────────────────
// event shape:
// {
// version: "0",
// id: "evt-abc123",
// source: "com.myapp.orders",
// account: "123456789012",
// time: "2026-01-20T12:00:00Z",
// region: "us-east-1",
// resources: [],
// detail-type: "order.placed", // ← string, maps to DetailType type param
// detail: { orderId: "o-1", customerId: "c-42", totalCents: 9900 }, // ← PARSED OBJECT
// }
// detail is already a parsed object — do NOT call JSON.parse(event.detail)
import type { EventBridgeEvent } from 'aws-lambda';
interface OrderPlaced { orderId: string; customerId: string; totalCents: number; }
export const handler = async (event: EventBridgeEvent<'order.placed', OrderPlaced>) => {
const { orderId, totalCents } = event.detail; // already typed, no JSON.parse()
// ...
};
// ── Partial batch failure for SQS ────────────────────────────────
import type { SQSEvent, SQSBatchResponse } from 'aws-lambda';
export const batchHandler = async (event: SQSEvent): Promise<SQSBatchResponse> => {
const failures: string[] = [];
await Promise.allSettled(
event.Records.map(async (record) => {
try {
const payload = JSON.parse(record.body);
await processRecord(payload);
} catch {
failures.push(record.messageId); // mark for retry
}
})
);
return {
batchItemFailures: failures.map(id => ({ itemIdentifier: id })),
};
// Only failed records are returned to the queue for retry
// Successful records are deleted from the queue automatically
};
// ── DLQ: route unprocessable messages ────────────────────────────
// Set MaxReceiveCount = 3 on the SQS queue's redrive policy
// After 3 failed attempts, SQS moves the message to the DLQ
// Lambda on the DLQ can log/alert without retrying indefinitelyThe SNS-to-SQS double-encoding is the most common source of JSON.parse errors in event-driven Lambda functions — developers forget that when SQS subscribes to SNS, the record.body is the SNS envelope JSON, not the application payload. Always check the record.body structure in tests and log the raw body before parsing during development. Partial batch failure reporting (SQSBatchResponse) is essential for production SQS consumers — without it, a single bad message causes the entire batch to retry, potentially blocking the queue until the message expires or reaches the DLQ.
Key Terms
- cold start
- The initialization phase that occurs when a serverless platform creates a new function instance — downloading the deployment package, starting the runtime, and executing module-level code (imports, SDK initialization, static JSON parsing). Cold starts add latency to the first request on a new instance; subsequent requests on the same instance (warm invocations) skip this phase. Node.js 20 Lambda cold starts without VPC are typically 200–300 ms; with VPC, add 500–1000 ms due to ENI attachment. Strategies: reduce bundle size, initialize SDKs at module scope, use Provisioned Concurrency to pre-warm instances. Cloudflare Workers have near-zero cold starts due to V8 isolate reuse. Vercel Edge Functions are also near-zero; Vercel Node.js functions have cold starts similar to Lambda.
- API Gateway proxy event
- A JSON object that AWS API Gateway constructs from an incoming HTTP request and passes to Lambda as the
eventargument when using proxy integration. The event containshttpMethod,path,headers,queryStringParameters,pathParameters,body(always a string), andisBase64Encoded. The corresponding response must be a JSON object withstatusCode(integer),headers(object), andbody(string). API Gateway v2 (HTTP API) uses a simplified event withrequestContext.http.methodinstead ofevent.httpMethod. Returningbodyas an object instead of a string causes a 502 Bad Gateway with “Malformed Lambda proxy response” in CloudWatch. - Lambda event object
- The first argument to an AWS Lambda handler function — a JavaScript object deserialized from the trigger-specific JSON payload by the Lambda runtime. The shape differs by trigger: API Gateway events contain HTTP request data; SQS events contain a
Recordsarray of messages withbodystrings; SNS events contain aRecordsarray withSns.Messagestrings; EventBridge events containsource,detail-type, anddetail(a parsed object, not a string). Install@types/aws-lambdafor TypeScript types that enforce the correct shape for each trigger. - provisioned concurrency
- An AWS Lambda configuration that pre-initializes a specified number of function instances and keeps them warm, eliminating cold starts for those instances. When a request arrives, it is immediately dispatched to an already-initialized instance — no download, no runtime start, no module-level JSON parsing. Provisioned concurrency is charged for the configured number of instances per hour, regardless of invocation count. For JSON-heavy functions where cold start cost is high (large schema initialization, many static JSON imports), provisioned concurrency is the most direct solution. Configure via
ProvisionedConcurrencyConfigin SAM/CloudFormation or via the Lambda console. Auto-scaling provisioned concurrency with Application Auto Scaling adjusts the count based on invocation metrics. - Edge runtime
- A JavaScript runtime based on V8 isolates (the same engine as Chrome) deployed geographically close to users — used by Vercel Edge Functions and Cloudflare Workers. Edge runtimes start in under 5 ms (no cold start), have a globally consistent deployment, and use Web APIs (Fetch, Response, Request, URL, crypto, TextEncoder/TextDecoder) rather than Node.js APIs. Constraints include no
Buffer, nofs, nonet, nochild_process, and runtime-specific size limits (Vercel: 4 MB response, 25 MB request). JSON handling usesrequest.json()for parsing andResponse.json()for returning — the same API as standard browser fetch. - structured logging
- A logging practice where each log entry is a JSON object with consistent, queryable fields — typically
level,message,ts, and a correlation ID likerequestId. Contrast with unstructured logging (console.log("user created", userId)), where values are embedded in a string and cannot be queried by field. CloudWatch Logs Insights queries structured JSON logs withfields @timestamp, level, message | filter level = "ERROR". The AWS Lambda Node.js 20 runtime natively parses JSON log lines whenAWS_LAMBDA_LOG_FORMAT=JSONis set. Datadog, Splunk, and other APM tools ingest structured JSON logs and correlate them with traces via injectedtrace_idfields. - SQS message envelope
- The outer JSON wrapper that AWS SQS adds around each message when delivering it to a Lambda trigger. The Lambda
event.Records[0]contains SQS metadata (messageId,receiptHandle,attributes,eventSource) plus abodyfield (string) containing the original message content. When SQS receives messages from SNS (fanout pattern), thebodyis itself a JSON-encoded SNS notification envelope with aMessagestring field containing the actual application payload — requiring twoJSON.parse()calls. The SQS batch item failure response (batchItemFailures) allows Lambda to return failedmessageIdvalues, causing only those messages to be retried rather than the entire batch.
FAQ
What is the JSON structure of an AWS Lambda event?
An AWS Lambda event is a JSON object deserialized by the runtime and passed as the first handler argument. The shape depends on the trigger. For API Gateway v1 proxy integration: { httpMethod, path, headers, queryStringParameters, pathParameters, body, isBase64Encoded, requestContext } — where body is always a string, never a parsed object. For API Gateway v2 (HTTP API): { requestContext: { http: { method, path } }, rawPath, rawQueryString, headers, body, isBase64Encoded }. For SQS: { Records: [{ messageId, body, attributes, eventSource }] } where body is your message string. For EventBridge: { source, "detail-type", detail } where detail is a parsed object. Install @types/aws-lambda for TypeScript types covering all trigger shapes.
How do I return a JSON response from a Lambda function?
For API Gateway proxy integration, return { statusCode: 200, headers: { "Content-Type": "application/json" }, body: JSON.stringify(data) }. The body must be a string — returning an object causes a 502 Bad Gateway with “Malformed Lambda proxy response” in CloudWatch. The statusCode must be an integer, not a string. Build a helper: const ok = (data) => ({ statusCode: 200, headers: { "Content-Type": "application/json" }, body: JSON.stringify(data) }) and use it consistently. For Vercel Edge and Node.js runtime routes, use return Response.json(data) or return Response.json(data, { status: 201 }). For async Lambda invocations (SQS, SNS, EventBridge), do not return an HTTP response — throw to signal failure or return void for success.
What is the maximum JSON payload size for Lambda?
Lambda enforces two hard limits. Synchronous invocations (API Gateway, Lambda URL, direct RequestResponse): 6 MB for both request and response. Asynchronous invocations (SQS, SNS, EventBridge, S3 event notifications): 256 KB event payload. These cannot be raised. Exceeding the sync limit returns 413 from API Gateway; exceeding the async limit silently drops the event. For large JSON, use the S3 claim-check pattern: write the payload to S3, pass the S3 key in the event, and have the consumer fetch from S3. Lambda Function URLs with InvokeMode: RESPONSE_STREAM bypass the 6 MB response limit and support up to 20 MB by streaming. For async events exceeding 256 KB, pass an S3 presigned URL instead of the full payload.
How do I reduce cold start time for JSON-heavy serverless functions?
The highest-impact optimizations: (1) Move all JSON.parse() calls for static data to module scope so they run once per instance, not per request. (2) For large static objects (country codes, config maps), use JSON.parse('{"key":"val"}')instead of a JS object literal — V8's JSON parser is measurably faster for 100+ key objects. (3) Minimize your deployment bundle with esbuild — remove unused JSON schema files, use modular AWS SDK v3 imports, and tree-shake dead code. (4) Use Lambda Provisioned Concurrency to pre-warm instances and eliminate cold starts entirely for latency-sensitive endpoints. Node.js 20 cold starts without VPC are 200–300 ms; with VPC add 500–1000 ms. Vercel Edge has near-zero cold starts.
How do I log JSON in AWS Lambda?
Write each log line as a JSON object with console.log(JSON.stringify({ level, message, requestId, ts, ...context })). Always include context.awsRequestId as the correlation ID — it links all log entries for a single invocation. CloudWatch Logs Insights queries structured JSON fields directly: fields @timestamp, level, message, requestId | filter level = "ERROR" | sort @timestamp desc. In Node.js 20+, set AWS_LAMBDA_LOG_FORMAT=JSON in the Lambda environment to enable native JSON logging — the runtime wraps plain objects written to console.log() in a structured envelope automatically. For Datadog APM, dd-trace injects trace_id into log output to correlate logs with traces. See our JSON logging guide for log schema design.
How do I handle JSON in Vercel Edge Functions?
To return JSON: return Response.json(data) — sets Content-Type: application/json and calls JSON.stringify() internally. To set status: Response.json(data, { status: 201 }). To read a request body: const body = await request.json() — wrap in try/catch and return 400 for invalid JSON. Vercel Edge has a 4 MB response size limit and a 25 MB request body limit. The Edge runtime has no Buffer — use atob() and TextDecoder for base64 decoding. Add export const runtime = 'edge' to opt a Next.js API route into the Edge runtime; without it, routes run on Node.js. For larger responses, use Vercel Blob Storage or stream from origin. See our JSON caching guide for Edge-compatible caching patterns.
How do I type Lambda JSON event objects in TypeScript?
Install @types/aws-lambda (npm install --save-dev @types/aws-lambda) and import named types for each trigger. API Gateway v1: import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'. API Gateway v2: APIGatewayProxyEventV2 and APIGatewayProxyResultV2. SQS: SQSEvent, SQSRecord, SQSBatchResponse. EventBridge: EventBridgeEvent<'detail-type', DetailShape> where DetailShape is your custom interface. For request body parsing, use a generic helper: function parseBody<T>(event): T | null { try { return JSON.parse(event.body ?? "{}") as T; } catch { return null; } }. The as Tcast is a type assertion, not runtime validation — use Zod for runtime validation in production.
How do I process SQS JSON messages in Lambda?
SQS delivers an event.Records array where each record has a body string — parse with JSON.parse(record.body). If the SQS queue subscribes to SNS, body is the SNS envelope JSON and your payload is nested at JSON.parse(record.body).Message — requiring a second parse. Use partial batch failure reporting to retry only failed records: process all records in a try/catch loop, collect failed messageId values, and return { batchItemFailures: failures.map(id => ({ itemIdentifier: id })) }. Without this, a single invalid record causes the entire batch to retry until the message expires or reaches the DLQ. Set MaxReceiveCount: 3 on the redrive policy so persistently failing messages move to a dead-letter queue instead of blocking the consumer.
Further reading and primary sources
- AWS Lambda: Working with the Node.js runtime — Official Lambda Node.js handler documentation — event and context object shapes, JSON response format
- API Gateway Proxy Integration Input/Output Format — AWS documentation for API Gateway proxy integration request and response JSON contract
- Vercel Functions: Route Handlers — Vercel documentation for Edge and Node.js serverless function runtimes and JSON response patterns
- Lambda Response Streaming — Lambda Function URL response streaming for JSON payloads exceeding the 6 MB limit
- CloudWatch Logs Insights Query Syntax — CloudWatch Logs Insights reference for querying structured JSON log fields