AWS Lambda with TypeScript: Production Best Practices
Moving from Express.js to Lambda: the common mistakes teams make along the way, and the TypeScript patterns that reduce AWS bills at scale.
A traditional Express.js API on EC2 delivers fixed costs, predictable scaling, and 99.9% uptime. The case for Lambda is usually triggered by a specific mismatch: a feature that needs to process 50,000 webhooks in under 10 minutes, once per month.
Keeping EC2 instances running 24/7 for a 10-minute monthly spike is wasteful, and Lambda removes that idle cost. What it adds is a different set of failure modes: an account-wide concurrency limit, cold starts on the critical path, and a billing model where one careless DynamoDB Scan costs more than all the compute around it.
Common Objections to Serverless
The standard objection to serverless is “vendor lock-in with extra steps.” Teams comfortable managing Kubernetes clusters and fine-tuning JVM garbage collectors see Lambda as giving up control. Three recurring scenarios tend to change that view:
The Unexpected Traffic Spike
An Express API featured on a major tech link aggregator can see traffic jump from 100 req/min to 5,000 req/min overnight. Auto-scaling groups typically need 6-10 minutes to spin up new instances. In that window, payment processing failures accumulate and Redis caches get overwhelmed.
Lambda has no instance to boot. Concurrency for a single function climbs by up to 1,000 execution environments every 10 seconds, which covers the shape of that traffic curve without a capacity decision.
The Webhook Processing Challenge
Processing Stripe webhooks that arrive in bursts of 10,000+ events exposes EC2’s two bad options:
- Over-provision for peak load (expensive)
- Use queues and risk webhook timeouts (unreliable)
Lambda’s automatic concurrency scaling removes the choice. Each webhook gets its own execution environment, so there is no queue to tune and no idle capacity to pay for between bursts.
Paying for Idle Capacity
Utilization reviews on always-on API servers usually find the fleet idle for most of the day while the bill covers full capacity around the clock. Lambda bills per millisecond of execution, so idle time costs nothing.
Production CDK Stack
A CDK stack with the settings that start to matter once traffic is real:
// Production CDK stack
import { Stack, StackProps, Duration, RemovalPolicy } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs';
import { RestApi, LambdaIntegration, Cors, MethodLoggingLevel } from 'aws-cdk-lib/aws-apigateway';
import { Table, AttributeType, BillingMode } from 'aws-cdk-lib/aws-dynamodb';
import { Runtime, Tracing } from 'aws-cdk-lib/aws-lambda';
export class ProductionServerlessStack extends Stack {
constructor(scope: Construct, id: string, props?: StackProps) {
super(scope, id, props);
// DynamoDB table - single-table design
const dataTable = new Table(this, 'DataTable', {
partitionKey: { name: 'PK', type: AttributeType.STRING },
sortKey: { name: 'SK', type: AttributeType.STRING },
billingMode: BillingMode.PAY_PER_REQUEST, // On-demand pricing handles traffic spikes
// Point-in-time recovery guards against accidental deletion
pointInTimeRecovery: true,
removalPolicy: RemovalPolicy.RETAIN, // Never accidentally delete prod data
});
// Add GSI for querying by different access patterns
dataTable.addGlobalSecondaryIndex({
indexName: 'GSI1',
partitionKey: { name: 'GSI1PK', type: AttributeType.STRING },
sortKey: { name: 'GSI1SK', type: AttributeType.STRING },
});
// Lambda function with production-ready settings
const apiHandler = new NodejsFunction(this, 'ApiHandler', {
entry: 'src/handlers/api.ts',
runtime: Runtime.NODEJS_20_X,
// Memory sized from profiling the real workload
memorySize: 1024, // Starting point for a JSON processing handler
timeout: Duration.seconds(28), // Just under API Gateway's 29s limit
environment: {
TABLE_NAME: dataTable.tableName,
NODE_ENV: 'production',
// Custom env vars
LOG_LEVEL: 'info',
ENABLE_X_RAY: 'true',
},
bundling: {
minify: true,
target: 'node20',
// Exclude aws-sdk from bundle - Lambda runtime provides it
externalModules: ['@aws-sdk/*'],
// Tree-shake unused code
treeShaking: true,
// Source maps for debugging prod issues
sourceMap: true,
// Define for dead code elimination
define: {
'process.env.NODE_ENV': '"production"',
},
},
// Enable X-Ray tracing for debugging
tracing: Tracing.ACTIVE,
// Reserved concurrency to prevent Lambda from consuming entire account limit
reservedConcurrentExecutions: 100,
});
// Grant DynamoDB permissions
dataTable.grantReadWriteData(apiHandler);
// API Gateway with proper CORS and throttling
const api = new RestApi(this, 'ServerlessApi', {
restApiName: 'production-serverless-api',
description: 'Production serverless API with proper error handling',
defaultCorsPreflightOptions: {
allowOrigins: process.env.NODE_ENV === 'production'
? ['https://yourdomain.com']
: Cors.ALL_ORIGINS,
allowMethods: Cors.ALL_METHODS,
allowHeaders: ['Content-Type', 'Authorization', 'X-Amz-Date'],
},
deployOptions: {
// Stage-specific throttling
throttlingRateLimit: 1000,
throttlingBurstLimit: 2000,
// Enable detailed CloudWatch metrics
metricsEnabled: true,
loggingLevel: MethodLoggingLevel.INFO,
// Enable X-Ray tracing
tracingEnabled: true,
},
});
// Add resource with proper integration
const items = api.root.addResource('items');
items.addMethod('GET', new LambdaIntegration(apiHandler));
items.addMethod('POST', new LambdaIntegration(apiHandler));
const singleItem = items.addResource('{id}');
singleItem.addMethod('GET', new LambdaIntegration(apiHandler));
singleItem.addMethod('PUT', new LambdaIntegration(apiHandler));
singleItem.addMethod('DELETE', new LambdaIntegration(apiHandler));
}
}
Production Lambda Handler
The handler side, with the client setup and error handling those failure modes call for:
// src/handlers/api.ts
import { APIGatewayProxyHandler, APIGatewayProxyResult } from 'aws-lambda';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, GetCommand, PutCommand, QueryCommand } from '@aws-sdk/lib-dynamodb';
// Create DynamoDB client outside handler for connection reuse
const dynamoClient = new DynamoDBClient({
region: process.env.AWS_REGION,
// Connection pooling settings for cost efficiency
maxAttempts: 3,
requestHandler: {
connectionTimeout: 1000,
socketTimeout: 1000,
},
});
const docClient = DynamoDBDocumentClient.from(dynamoClient, {
marshallOptions: {
removeUndefinedValues: true, // Prevents DynamoDB validation errors
convertEmptyValues: false,
},
});
interface Item {
id: string;
name: string;
description?: string;
createdAt: string;
updatedAt: string;
}
// The handler that processes high-volume requests
export const handler: APIGatewayProxyHandler = async (event): Promise<APIGatewayProxyResult> => {
// Performance optimization: parse once, use everywhere
const { httpMethod, pathParameters, body, requestContext } = event;
const requestId = requestContext.requestId;
// Structured logging that actually helps during incidents
console.log('Request received', {
requestId,
method: httpMethod,
path: event.path,
pathParams: pathParameters,
userAgent: event.headers['User-Agent'],
sourceIp: event.requestContext.identity.sourceIp,
});
try {
switch (httpMethod) {
case 'GET':
return await handleGet(pathParameters?.id, requestId);
case 'POST':
return await handlePost(body, requestId);
case 'PUT':
return await handlePut(pathParameters?.id, body, requestId);
case 'DELETE':
return await handleDelete(pathParameters?.id, requestId);
default:
return createResponse(405, { error: 'Method not allowed' });
}
} catch (error) {
// Log the failure once, at the boundary, with the correlating request ID
console.error('Handler error', {
requestId,
error: error.message,
stack: error.stack,
// Sanitized request data (never log sensitive info)
method: httpMethod,
path: event.path,
});
// Different error responses based on error type
if (error.name === 'ValidationException') {
return createResponse(400, { error: 'Invalid request data' });
}
if (error.name === 'ConditionalCheckFailedException') {
return createResponse(409, { error: 'Resource conflict' });
}
if (error.name === 'ResourceNotFoundException') {
return createResponse(404, { error: 'Resource not found' });
}
// Generic server error for unexpected issues
return createResponse(500, {
error: 'Internal server error',
requestId, // Include for support tickets
});
}
};
async function handleGet(id: string | undefined, requestId: string): Promise<APIGatewayProxyResult> {
if (!id) {
// List all items with pagination
const result = await docClient.send(new QueryCommand({
TableName: process.env.TABLE_NAME!,
KeyConditionExpression: 'PK = :pk',
ExpressionAttributeValues: {
':pk': 'ITEM',
},
Limit: 50, // Prevent large scans that timeout
}));
const items = result.Items?.map(item => ({
id: item.SK.replace('ITEM#', ''),
name: item.name,
description: item.description,
createdAt: item.createdAt,
updatedAt: item.updatedAt,
})) || [];
return createResponse(200, { items, count: items.length, requestId });
}
// Get single item
const result = await docClient.send(new GetCommand({
TableName: process.env.TABLE_NAME!,
Key: {
PK: 'ITEM',
SK: `ITEM#${id}`,
},
}));
if (!result.Item) {
return createResponse(404, { error: 'Item not found', requestId });
}
const item: Item = {
id: result.Item.SK.replace('ITEM#', ''),
name: result.Item.name,
description: result.Item.description,
createdAt: result.Item.createdAt,
updatedAt: result.Item.updatedAt,
};
return createResponse(200, { item, requestId });
}
async function handlePost(body: string | null, requestId: string): Promise<APIGatewayProxyResult> {
if (!body) {
return createResponse(400, { error: 'Request body is required', requestId });
}
let data: Partial<Item>;
try {
data = JSON.parse(body);
} catch (error) {
return createResponse(400, { error: 'Invalid JSON', requestId });
}
// Reject bad input before it reaches DynamoDB
if (!data.name || typeof data.name !== 'string' || data.name.trim().length === 0) {
return createResponse(400, { error: 'Name is required and must be a non-empty string', requestId });
}
if (data.name.length > 100) {
return createResponse(400, { error: 'Name must be 100 characters or less', requestId });
}
const id = generateId(); // Custom ID generation
const now = new Date().toISOString();
const item: Item = {
id,
name: data.name.trim(),
description: data.description?.trim() || undefined,
createdAt: now,
updatedAt: now,
};
// Single-table design with composite keys
await docClient.send(new PutCommand({
TableName: process.env.TABLE_NAME!,
Item: {
PK: 'ITEM',
SK: `ITEM#${id}`,
...item,
// GSI keys for alternative access patterns
GSI1PK: 'ITEMS_BY_NAME',
GSI1SK: item.name.toLowerCase(),
},
// Prevent overwriting existing items
ConditionExpression: 'attribute_not_exists(PK)',
}));
console.log('Item created', { requestId, itemId: id });
return createResponse(201, { item, requestId });
}
// Utility function for consistent responses
function createResponse(statusCode: number, body: any): APIGatewayProxyResult {
return {
statusCode,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*', // Adjust for production
'Access-Control-Allow-Headers': 'Content-Type,Authorization',
'X-Request-ID': body.requestId || 'unknown',
},
body: JSON.stringify(body),
};
}
// Generate URL-safe unique IDs
function generateId(): string {
return `${Date.now().toString(36)}-${Math.random().toString(36).substr(2, 9)}`;
}
Cost Optimization Patterns
1. Memory vs. CPU Trade-offs
Lambda allocates CPU in proportion to configured memory, so the memory setting is really a speed dial. Doubling memory often halves duration, which keeps the bill flat while the function gets faster. Past the point where the work stops being CPU-bound, duration flattens out and the extra memory turns into pure cost.
Where that point sits depends on the handler. AWS Lambda Power Tuning runs one function across a range of memory settings with a real payload and plots cost against duration. 1024 MB is a reasonable place to start; the sweep is what confirms it.
2. Connection Reuse
Every TLS handshake costs billed milliseconds, and on short DynamoDB calls the handshake can outweigh the operation itself. The AWS SDK for JavaScript v3 reuses TCP connections by default, so what matters here is where the client is constructed:
// Inside the handler: a new client, and a new handshake, on every invocation
export const handler = async () => {
const client = new DynamoDBClient({});
// ...
};
// Outside the handler: one client per execution environment, reused while warm
const dynamoClient = new DynamoDBClient({
region: process.env.AWS_REGION,
maxAttempts: 3,
requestHandler: {
connectionTimeout: 1000,
socketTimeout: 1000,
},
});
AWS_NODEJS_CONNECTION_REUSE_ENABLED=1 still shows up in a lot of examples. It is a v2 setting; v3 ignores it because keep-alive is already on.
3. Bundle Size Optimization
The runtime downloads and unpacks the deployment package before the first line of the handler runs, so bundle size lands directly on cold start duration:
bundling: {
minify: true,
target: 'node20',
externalModules: [
'@aws-sdk/*', // Provided by the Node.js runtime
],
treeShaking: true,
sourceMap: process.env.NODE_ENV !== 'production', // Debug info only in dev
define: {
'process.env.NODE_ENV': '"production"',
},
banner: '/* Production Lambda bundle */',
}
Tree-shaking only helps when the import allows it. A default import of a CommonJS utility library pulls the whole package in; a submodule import pulls one function:
// Pulls in the whole library
import _ from 'lodash';
// Pulls in one function
import throttle from 'lodash/throttle';
4. CloudWatch Logs Volume
CloudWatch Logs ingestion is billed per gigabyte. High-volume info logging can dominate the bill on its own. A structured logger keyed off LOG_LEVEL keeps errors and warnings always visible while suppressing verbose info output in production:
// Errors and warnings always emit; info only at info/debug level, so LOG_LEVEL=warn mutes it
const LEVELS = { error: 0, warn: 1, info: 2, debug: 3 } as const;
const threshold = LEVELS[(process.env.LOG_LEVEL as keyof typeof LEVELS) ?? 'info'] ?? LEVELS.info;
const logger = {
error: (message: string, meta?: any) => {
console.error(JSON.stringify({ level: 'error', message, meta, timestamp: new Date().toISOString() }));
},
warn: (message: string, meta?: any) => {
console.warn(JSON.stringify({ level: 'warn', message, meta, timestamp: new Date().toISOString() }));
},
info: (message: string, meta?: any) => {
if (threshold >= LEVELS.info) {
console.log(JSON.stringify({ level: 'info', message, meta, timestamp: new Date().toISOString() }));
}
},
};
5. DynamoDB Billing Mode
Billing mode is a cost lever that depends on traffic shape. On-demand (PAY_PER_REQUEST) absorbs unpredictable spikes without capacity planning. Provisioned capacity is cheaper for steady, predictable throughput:
// On-demand for write-heavy, spiky workloads
const writeHeavyTable = new Table(this, 'WriteHeavyTable', {
billingMode: BillingMode.PAY_PER_REQUEST, // Cost-effective under spikes
});
// Provisioned for predictable workloads
const predictableTable = new Table(this, 'PredictableTable', {
billingMode: BillingMode.PROVISIONED,
readCapacity: 5,
writeCapacity: 5,
});
Production Monitoring Setup
An alarm is only useful when someone would act on it. Four of them cover most Lambda failure modes:
import { Duration } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { Alarm, Metric, TreatMissingData } from 'aws-cdk-lib/aws-cloudwatch';
import { Function } from 'aws-cdk-lib/aws-lambda';
export class ServerlessMonitoring extends Construct {
constructor(scope: Construct, id: string, props: { lambdaFunction: Function }) {
super(scope, id);
// Errors averaged over the period give the share of invocations that failed
const errorAlarm = new Alarm(this, 'HighErrorRate', {
metric: props.lambdaFunction.metricErrors({
statistic: 'Average',
period: Duration.minutes(5),
}),
threshold: 0.05, // 5% of invocations failing
evaluationPeriods: 2,
treatMissingData: TreatMissingData.NOT_BREACHING,
});
// Duration alarm - 95th percentile over 5 seconds
const durationAlarm = new Alarm(this, 'SlowRequests', {
metric: props.lambdaFunction.metricDuration({
statistic: 'p95',
period: Duration.minutes(5),
}),
threshold: 5000, // 5 seconds
evaluationPeriods: 3,
});
// Throttle alarm - any throttling is bad
const throttleAlarm = new Alarm(this, 'ThrottledRequests', {
metric: props.lambdaFunction.metricThrottles({
statistic: 'Sum',
period: Duration.minutes(1),
}),
threshold: 1,
evaluationPeriods: 1,
});
// Custom metric for business logic errors
const businessErrorAlarm = new Alarm(this, 'BusinessLogicErrors', {
metric: new Metric({
namespace: 'MyApp/Lambda',
metricName: 'BusinessErrors',
statistic: 'Sum',
}),
threshold: 10,
evaluationPeriods: 2,
});
}
}
Common Production Mistakes
1. The Concurrent Execution Limit Issue
Concurrency is pooled at the account level, and every function in the account draws from the same pool. Under a burst, webhook processing Lambdas can consume all 1,000 concurrent executions in an account still on the default quota. The customer-facing API then fails, because there is no capacity left for it.
Fix: Set reserved concurrency on critical functions:
reservedConcurrentExecutions: 100, // Guarantee capacity
2. The DynamoDB Hot Partition Problem
Sequential IDs as partition keys send consecutive writes to the same partition. DynamoDB throttles per partition, so the table starts returning throttling errors long before the table-level capacity is used up.
Fix: Partition keys that spread across the key space:
// Bad: sequential IDs concentrate writes on one partition
PK: `USER#${sequentialId}`
// Good: a random ID spreads writes, and still reads back by key
PK: `USER#${randomUUID()}`
3. The 15-Minute Execution Ceiling
Stopping at exactly 15 minutes points at one cause: the hard maximum execution time, which no configuration raises. Synchronous processing of a large batch runs into this ceiling as soon as the batch grows past what fits in the window.
Fix: Batch processing with pagination:
// Process in smaller chunks
const BATCH_SIZE = 100;
const MAX_EXECUTION_TIME = 14 * 60 * 1000; // 14 minutes
const startTime = Date.now();
for (let i = 0; i < items.length; i += BATCH_SIZE) {
if (Date.now() - startTime > MAX_EXECUTION_TIME) {
// Schedule continuation via SQS
await scheduleRemainingWork(items.slice(i));
break;
}
const batch = items.slice(i, i + BATCH_SIZE);
await processBatch(batch);
}
4. The DynamoDB Scan Cost Trap
Scan reads the entire table and bills for every item examined, not the few that match. On a large table this turns into a significant, recurring cost. A Global Secondary Index plus Query reads only the matching partition:
// Reads the table front to back and bills for every item examined
const getAllUsers = async () => {
const result = await docClient.send(new ScanCommand({
TableName: process.env.TABLE_NAME,
}));
return result.Items; // One 1 MB page; a full pass repeats this cost
};
// Fix: use Query
const getUsersByStatus = async (status: string) => {
const result = await docClient.send(new QueryCommand({
TableName: process.env.TABLE_NAME,
IndexName: 'GSI1',
KeyConditionExpression: 'GSI1PK = :pk',
ExpressionAttributeValues: {
':pk': `STATUS#${status}`,
},
}));
return result.Items;
};
5. The Lambda Memory Leak
A warm Lambda container reuses module-global state across invocations. Anything appended to a module-level object grows unbounded until the container is recycled, eventually exhausting memory. Request-scoped state lives and dies with a single invocation:
// Wrong: accumulating data in module globals
let cache: any = {}; // Causes a memory leak across Lambda instances
export const handler = async (event: APIGatewayProxyEvent) => {
cache[event.requestContext.requestId] = event; // Memory leak
// ...
};
// Right: clean state per request
export const handler = async (event: APIGatewayProxyEvent) => {
const requestCache = new Map(); // Local scope
// ...
};
TypeScript Patterns for Production Reliability
1. Strict Event Type Definitions
// Custom type definitions for better IntelliSense
interface StrictAPIGatewayEvent extends APIGatewayProxyEvent {
pathParameters: { [key: string]: string }; // Non-null on routes declaring a path parameter
body: string; // Always present for POST/PUT
}
// Type guards for runtime safety
function isValidItemData(data: any): data is Partial<Item> {
return typeof data === 'object' &&
data !== null &&
(data.name === undefined || typeof data.name === 'string');
}
2. Environment Variable Validation
// Fail fast when a required variable is missing
interface Environment {
TABLE_NAME: string;
LOG_LEVEL: 'debug' | 'info' | 'warn' | 'error';
NODE_ENV: 'development' | 'production';
}
function validateEnvironment(): Environment {
const env = process.env;
if (!env.TABLE_NAME) {
throw new Error('TABLE_NAME environment variable is required');
}
return {
TABLE_NAME: env.TABLE_NAME,
LOG_LEVEL: (env.LOG_LEVEL as any) || 'info',
NODE_ENV: (env.NODE_ENV as any) || 'development',
};
}
// Validate once at module load
const ENV = validateEnvironment();
3. Result Types for Error Handling
// Rust-inspired Result type for clean error handling
type Result<T, E = Error> =
| { success: true; data: T }
| { success: false; error: E };
async function getItem(id: string): Promise<Result<Item, string>> {
try {
const result = await docClient.send(new GetCommand({
TableName: ENV.TABLE_NAME,
Key: { PK: 'ITEM', SK: `ITEM#${id}` },
}));
if (!result.Item) {
return { success: false, error: 'Item not found' };
}
return { success: true, data: transformDynamoItem(result.Item) };
} catch (error) {
return { success: false, error: error.message };
}
}
// Usage
const result = await getItem(id);
if (!result.success) {
return createResponse(404, { error: result.error });
}
// TypeScript knows result.data is Item
const item = result.data;
Where the Numbers Come From
Published cold-start and cost figures rarely transfer between workloads, because they depend on bundle size, runtime, memory setting and payload shape. Three sources give the numbers for a specific function:
- Cold starts: the
REPORTline in CloudWatch Logs carriesInit Durationfor every cold invocation. Querying that field in Logs Insights gives the distribution, which is more useful than an average that hides the tail. - Duration and limits: the
Duration,ConcurrentExecutionsandThrottlesmetrics separate a slow handler from a function that is queueing behind a concurrency limit. - Cost split: Cost Explorer grouped by service splits the bill across Lambda compute, API Gateway requests, DynamoDB and CloudWatch Logs ingestion. Log ingestion is the line item that surprises teams most often.
Take those readings before tuning anything, so the change has a baseline to beat.
When NOT to Use Serverless
Serverless is not always the right tool. Containers remain the better choice for:
- Long-running processes - Video encoding, large batch jobs
- Websocket-heavy apps - Real-time gaming, chat apps
- Legacy applications - Complex deployment requirements
- Stateful workloads - In-memory caches, sessions
- Cold start sensitive - Sub-100ms response requirements
Deployment Pipeline
Lambda points an alias at a new version instead of draining and restarting servers, so a deploy costs no downtime. The risk that remains is a bad version reaching every caller at once, which is what the test gate and the approval step below are for:
// CDK pipeline: test stage, integration tests, approval, then prod
export class ServerlessPipeline extends Stack {
constructor(scope: Construct, id: string) {
super(scope, id);
const pipeline = new CodePipeline(this, 'Pipeline', {
synth: new ShellStep('Synth', {
input: CodePipelineSource.gitHub('yourorg/repo', 'main'),
commands: [
'npm ci',
'npm run build',
'npm run test',
'npx cdk synth',
],
}),
});
// Stage deployments with gradual rollout
const testStage = new ServerlessStage(this, 'Test', {
stageName: 'test',
});
const prodStage = new ServerlessStage(this, 'Prod', {
stageName: 'prod',
});
pipeline.addStage(testStage, {
post: [
new ShellStep('IntegrationTests', {
commands: [
'npm run test:integration',
],
envFromCfnOutput: {
API_URL: testStage.apiUrl,
},
}),
],
});
pipeline.addStage(prodStage, {
pre: [
new ManualApprovalStep('PromoteToProd'),
],
post: [
new ShellStep('SmokeTests', {
commands: [
'npm run test:smoke',
],
}),
],
});
}
}
For a gradual rollout on top of this, put an alias in front of the function and let CodeDeploy shift traffic to the new version in steps, with a CloudWatch alarm as the rollback trigger.
Where This Setup Works
Lambda with TypeScript is the option worth reaching for when traffic is bursty or unpredictable and each request finishes in seconds rather than minutes. Under that profile, per-millisecond billing costs less than idle instances and concurrency scaling replaces capacity planning. Outside it, in the cases listed above, a container is the simpler answer, and the gap widens as traffic flattens out.
Most of the distance between a working Lambda and a cheap one comes down to two settings: the memory size, found with a power-tuning sweep, and the log level, which decides how much CloudWatch ingestion lands on the bill. Both are one-line changes, and both are worth revisiting once real traffic has run through the function.
References
- What is AWS Lambda? - AWS Lambda - Foundational concepts: execution model, pricing, and supported runtimes
- Building Lambda functions with TypeScript - AWS Lambda - Official guide to transpiling TypeScript and deploying to Lambda with CDK or zip archives
- Best practices for working with AWS Lambda functions - AWS guidance on handler initialization, connection reuse, and bundle size
- Understanding Lambda function scaling - AWS Lambda - How Lambda scales concurrency, reserved concurrency, and burst limits
- Best practices for designing and architecting with DynamoDB - Single-table design, partition key selection, and avoiding hot partitions
- Reuse connections with keep-alive in Node.js - AWS SDK for JavaScript v3 - Why v3 reuses TCP connections by default and how to change that per client
- Monitoring and troubleshooting Lambda functions - CloudWatch metrics, log fields including Init Duration, and tracing options
- AWS Lambda Power Tuning - Step Functions state machine that sweeps memory settings and plots cost against duration
- AWS CDK v2 Developer Guide - Infrastructure-as-code framework used throughout the production CDK stack examples
- Serverless Applications Lens - AWS Well-Architected Framework - Well-Architected guidance covering cost optimization, reliability, and performance for serverless workloads
Related posts
Build a testing strategy for AWS Lambda, API Gateway, DynamoDB, and Step Functions with practical patterns for fast feedback and reliability.
Set up a production-grade link shortener with AWS CDK, DynamoDB, and Lambda: architecture decisions, project layout, and the schema choices that hold up at scale.
Build the redirect engine, analytics collection, and API Gateway config: performance optimizations and debugging strategies for millions of daily redirects.
Keep a Lambda API and its OpenAPI contract in sync by generating the spec from Zod schemas and wiring that generation into CDK deployment.
Before building an internal service layer, decide whether you need one: what it costs per call, the volume where VPC Lattice wins, and when direct invoke still beats it.