Skip to main content
Back to Blog
11 October 202515 min read

AWS Lambda: Building Serverless Applications

AWSLambdaServerlessCloud

A comprehensive guide to AWS Lambda for serverless computing. Function design patterns, cold starts, performance optimization, and production best practices.


AWS Lambda: Building Serverless Applications

AWS Lambda lets you run code without provisioning or managing servers. You pay only for compute time consumed—no charge when your code isn't running. Understanding Lambda's execution model and best practices is essential for building reliable serverless applications.

Lambda Fundamentals

Execution Model

Lambda Execution Lifecycle:
├── Cold Start
│   ├── Download code from S3/Container registry
│   ├── Create execution environment
│   ├── Initialize runtime
│   ├── Run initialization code (outside handler)
│   └── Execute handler
│
└── Warm Start (reused environment)
    └── Execute handler directly

Environment Reuse:
┌─────────────────────────────────────────────────────┐
│              Execution Environment                   │
│  ┌──────────────────────────────────────────────┐   │
│  │         /tmp (512MB-10GB, persists)          │   │
│  └──────────────────────────────────────────────┘   │
│  ┌──────────────────────────────────────────────┐   │
│  │    Initialization Code (runs once)           │   │
│  │    - SDK clients                             │   │
│  │    - Database connections                    │   │
│  │    - Configuration loading                   │   │
│  └──────────────────────────────────────────────┘   │
│  ┌──────────────────────────────────────────────┐   │
│  │    Handler (runs per invocation)             │   │
│  └──────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────┘

Configuration Options

SettingRangeDefaultConsideration
Memory128MB - 10,240MB128MBCPU scales proportionally
Timeout1s - 900s3sMax 15 minutes
Ephemeral Storage512MB - 10,240MB512MB/tmp directory
Concurrent ExecutionsAccount limit1,000Can be reserved

Function Design Patterns

Basic Handler Structure

// handler.ts import { APIGatewayProxyEvent, APIGatewayProxyResult, Context } from 'aws-lambda'; // Initialization code - runs once per cold start const dynamoClient = new DynamoDBClient({}); const docClient = DynamoDBDocumentClient.from(dynamoClient); // Handler - runs per invocation export const handler = async ( event: APIGatewayProxyEvent, context: Context ): Promise<APIGatewayProxyResult> => { // Log request for debugging console.log('Request ID:', context.awsRequestId); console.log('Remaining time:', context.getRemainingTimeInMillis()); try { const body = JSON.parse(event.body || '{}'); // Business logic const result = await processRequest(body); return { statusCode: 200, headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }, body: JSON.stringify(result) }; } catch (error) { console.error('Error processing request:', error); return { statusCode: error instanceof ValidationError ? 400 : 500, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ error: error instanceof ValidationError ? error.message : 'Internal server error' }) }; } };

Event-Driven Patterns

// event-patterns.ts // Pattern 1: SQS Message Processing import { SQSEvent, SQSRecord } from 'aws-lambda'; export const sqsHandler = async (event: SQSEvent): Promise<void> => { const failedMessageIds: string[] = []; for (const record of event.Records) { try { await processMessage(record); } catch (error) { console.error(`Failed to process message ${record.messageId}:`, error); failedMessageIds.push(record.messageId); } } // Partial batch failure reporting if (failedMessageIds.length > 0) { throw new Error(`Failed messages: ${failedMessageIds.join(', ')}`); } }; // Pattern 2: S3 Event Processing import { S3Event } from 'aws-lambda'; 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, ' ')); console.log(`Processing ${bucket}/${key}`); if (record.eventName.startsWith('ObjectCreated')) { await processNewObject(bucket, key); } else if (record.eventName.startsWith('ObjectRemoved')) { await handleDeletedObject(bucket, key); } } }; // Pattern 3: DynamoDB Stream Processing import { DynamoDBStreamEvent, StreamRecord } from 'aws-lambda'; export const dynamoHandler = async (event: DynamoDBStreamEvent): Promise<void> => { for (const record of event.Records) { const eventName = record.eventName; const keys = record.dynamodb?.Keys; const newImage = record.dynamodb?.NewImage; const oldImage = record.dynamodb?.OldImage; switch (eventName) { case 'INSERT': await handleInsert(newImage); break; case 'MODIFY': await handleModify(oldImage, newImage); break; case 'REMOVE': await handleRemove(oldImage); break; } } }; // Pattern 4: Scheduled Events (CloudWatch Events/EventBridge) import { ScheduledEvent } from 'aws-lambda'; export const scheduledHandler = async (event: ScheduledEvent): Promise<void> => { console.log(`Running scheduled task at ${event.time}`); // Run cleanup, aggregation, or batch jobs await runScheduledTask(); };

Cold Start Optimisation

Reducing Cold Start Time

// cold-start-optimization.ts // 1. Keep dependencies minimal // Bad: import entire AWS SDK import AWS from 'aws-sdk'; // Imports everything // Good: import only what you need import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; // 2. Initialize outside handler const client = new DynamoDBClient({}); // Runs once on cold start export const handler = async (event: any) => { // Handler code uses pre-initialized client return await client.send(command); }; // 3. Use lazy initialization for rarely-used dependencies let s3Client: S3Client | null = null; const getS3Client = (): S3Client => { if (!s3Client) { s3Client = new S3Client({}); } return s3Client; }; // 4. Connection pooling for databases import { Pool } from 'pg'; const pool = new Pool({ host: process.env.DB_HOST, max: 1, // Lambda best practice: minimal connections idleTimeoutMillis: 120000, connectionTimeoutMillis: 5000 });

Provisioned Concurrency

# serverless.yml functions: api: handler: src/handler.main provisionedConcurrency: 5 # Keep 5 instances warm # terraform resource "aws_lambda_provisioned_concurrency_config" "api" { function_name = aws_lambda_function.api.function_name provisioned_concurrent_executions = 5 qualifier = aws_lambda_function.api.version }

Cold Start Comparison by Runtime

RuntimeCold Start (p50)Cold Start (p99)Notes
Node.js 20.x150-300ms500-800msFast startup
Python 3.12150-300ms500-800msFast startup
Go 1.x100-200ms300-500msFastest
Java 211-3s3-8sUse SnapStart
.NET 8400-800ms1-2sAOT helps

Error Handling and Retry Logic

Implementing Idempotency

// idempotency.ts import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; import { DynamoDBDocumentClient, PutCommand, GetCommand } from '@aws-sdk/lib-dynamodb'; const docClient = DynamoDBDocumentClient.from(new DynamoDBClient({})); interface IdempotencyRecord { idempotencyKey: string; status: 'IN_PROGRESS' | 'COMPLETED' | 'FAILED'; result?: any; expiresAt: number; } const IDEMPOTENCY_TABLE = process.env.IDEMPOTENCY_TABLE!; const TTL_SECONDS = 3600; // 1 hour export const withIdempotency = async <T>( idempotencyKey: string, operation: () => Promise<T> ): Promise<T> => { // Check for existing execution const existing = await docClient.send(new GetCommand({ TableName: IDEMPOTENCY_TABLE, Key: { idempotencyKey } })); if (existing.Item) { const record = existing.Item as IdempotencyRecord; if (record.status === 'COMPLETED') { console.log(`Returning cached result for ${idempotencyKey}`); return record.result as T; } if (record.status === 'IN_PROGRESS') { throw new Error('Operation already in progress'); } } // Mark as in progress const expiresAt = Math.floor(Date.now() / 1000) + TTL_SECONDS; await docClient.send(new PutCommand({ TableName: IDEMPOTENCY_TABLE, Item: { idempotencyKey, status: 'IN_PROGRESS', expiresAt }, ConditionExpression: 'attribute_not_exists(idempotencyKey) OR #status = :failed', ExpressionAttributeNames: { '#status': 'status' }, ExpressionAttributeValues: { ':failed': 'FAILED' } })); try { const result = await operation(); // Mark as completed await docClient.send(new PutCommand({ TableName: IDEMPOTENCY_TABLE, Item: { idempotencyKey, status: 'COMPLETED', result, expiresAt } })); return result; } catch (error) { // Mark as failed await docClient.send(new PutCommand({ TableName: IDEMPOTENCY_TABLE, Item: { idempotencyKey, status: 'FAILED', error: (error as Error).message, expiresAt } })); throw error; } }; // Usage export const handler = async (event: APIGatewayProxyEvent) => { const requestId = event.headers['x-request-id'] || event.requestContext.requestId; const result = await withIdempotency(requestId, async () => { return await processPayment(event); }); return { statusCode: 200, body: JSON.stringify(result) }; };

Dead Letter Queues

// dlq-handler.ts import { SQSEvent, SQSRecord } from 'aws-lambda'; interface DLQMessage { originalMessage: any; errorMessage: string; failureCount: number; originalQueue: string; timestamp: string; } export const dlqHandler = async (event: SQSEvent): Promise<void> => { for (const record of event.Records) { const dlqMessage = parseDLQMessage(record); // Log for analysis console.error('DLQ Message:', JSON.stringify({ messageId: record.messageId, errorMessage: dlqMessage.errorMessage, failureCount: dlqMessage.failureCount, originalQueue: dlqMessage.originalQueue })); // Alert if threshold exceeded if (dlqMessage.failureCount >= 3) { await sendAlert({ type: 'DLQ_THRESHOLD', message: `Message failed ${dlqMessage.failureCount} times`, messageId: record.messageId }); } // Store for later analysis/retry await storeDLQMessage(dlqMessage); } };

Observability

Structured Logging

// logger.ts interface LogContext { requestId: string; functionName: string; functionVersion: string; traceId?: string; } class StructuredLogger { private context: LogContext; constructor(context: LogContext) { this.context = context; } info(message: string, data?: Record<string, any>): void { this.log('INFO', message, data); } error(message: string, error?: Error, data?: Record<string, any>): void { this.log('ERROR', message, { ...data, error: error ? { name: error.name, message: error.message, stack: error.stack } : undefined }); } private log(level: string, message: string, data?: Record<string, any>): void { console.log(JSON.stringify({ timestamp: new Date().toISOString(), level, message, ...this.context, ...data })); } } // Usage in handler export const handler = async (event: any, context: Context) => { const logger = new StructuredLogger({ requestId: context.awsRequestId, functionName: context.functionName, functionVersion: context.functionVersion, traceId: process.env._X_AMZN_TRACE_ID }); logger.info('Processing request', { eventType: event.type }); try { const result = await processEvent(event); logger.info('Request completed', { resultCount: result.length }); return result; } catch (error) { logger.error('Request failed', error as Error); throw error; } };

Custom Metrics

// metrics.ts import { CloudWatchClient, PutMetricDataCommand } from '@aws-sdk/client-cloudwatch'; const cloudwatch = new CloudWatchClient({}); interface Metric { name: string; value: number; unit: 'Count' | 'Milliseconds' | 'Bytes'; dimensions?: Record<string, string>; } const publishMetrics = async (metrics: Metric[]): Promise<void> => { const namespace = `${process.env.SERVICE_NAME}/Lambda`; await cloudwatch.send(new PutMetricDataCommand({ Namespace: namespace, MetricData: metrics.map(m => ({ MetricName: m.name, Value: m.value, Unit: m.unit, Timestamp: new Date(), Dimensions: m.dimensions ? Object.entries(m.dimensions).map(([Name, Value]) => ({ Name, Value })) : undefined })) })); }; // Timing wrapper const withTiming = async <T>( name: string, operation: () => Promise<T> ): Promise<T> => { const start = Date.now(); try { const result = await operation(); await publishMetrics([{ name: `${name}Duration`, value: Date.now() - start, unit: 'Milliseconds' }, { name: `${name}Success`, value: 1, unit: 'Count' }]); return result; } catch (error) { await publishMetrics([{ name: `${name}Error`, value: 1, unit: 'Count' }]); throw error; } };

Infrastructure as Code

Terraform Configuration

# lambda.tf resource "aws_lambda_function" "api" { function_name = "${var.service_name}-api" role = aws_iam_role.lambda_role.arn handler = "dist/handler.main" runtime = "nodejs20.x" filename = data.archive_file.lambda_zip.output_path source_code_hash = data.archive_file.lambda_zip.output_base64sha256 memory_size = 1024 timeout = 30 environment { variables = { NODE_ENV = var.environment TABLE_NAME = aws_dynamodb_table.main.name LOG_LEVEL = "INFO" } } vpc_config { subnet_ids = var.private_subnet_ids security_group_ids = [aws_security_group.lambda.id] } tracing_config { mode = "Active" } reserved_concurrent_executions = 100 dead_letter_config { target_arn = aws_sqs_queue.dlq.arn } tags = var.tags } # IAM Role resource "aws_iam_role" "lambda_role" { name = "${var.service_name}-lambda-role" assume_role_policy = jsonencode({ Version = "2012-10-17" Statement = [{ Action = "sts:AssumeRole" Effect = "Allow" Principal = { Service = "lambda.amazonaws.com" } }] }) } resource "aws_iam_role_policy_attachment" "lambda_basic" { role = aws_iam_role.lambda_role.name policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole" } resource "aws_iam_role_policy" "lambda_permissions" { name = "${var.service_name}-lambda-permissions" role = aws_iam_role.lambda_role.id policy = jsonencode({ Version = "2012-10-17" Statement = [ { Effect = "Allow" Action = [ "dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:Query", "dynamodb:UpdateItem" ] Resource = aws_dynamodb_table.main.arn }, { Effect = "Allow" Action = [ "sqs:SendMessage" ] Resource = aws_sqs_queue.dlq.arn } ] }) } # CloudWatch Log Group resource "aws_cloudwatch_log_group" "lambda" { name = "/aws/lambda/${aws_lambda_function.api.function_name}" retention_in_days = 14 }

Key Takeaways

  1. Initialise outside handler: Move SDK clients and connections outside the handler function

  2. Minimise dependencies: Smaller deployment packages mean faster cold starts

  3. Handle errors gracefully: Implement idempotency and use DLQs for failed messages

  4. Monitor everything: Structured logging and custom metrics are essential

  5. Right-size memory: More memory = more CPU = potentially lower duration costs

  6. Consider provisioned concurrency: For latency-sensitive workloads

  7. Use layers: Share common code and reduce deployment size

  8. Design for failure: Assume functions can fail and build in retry logic

Lambda enables building scalable, cost-effective applications. Focus on function design, proper error handling, and observability to build production-ready serverless systems.

Share this article