Quick Takeaways
What you'll learn in this article
- 1
Services maintaining persistent WebSocket connections
- 2
Applications loading large ML models into memory (multi-GB)
- 3
Long-running batch processes exceeding 15 minutes
- 4
Services requiring more than 10 GB of memory
- 5
Applications with complex multi-threaded processing
Keep reading for detailed implementation, code examples, and real-world results
I have been building serverless systems on AWS since Lambda was limited to Node.js 4.3 with a 5-minute timeout and 1.5 GB of memory. Over the past eight years, I have deployed serverless architectures that handle everything from real-time payment processing at 50,000 transactions per second to event-driven data pipelines ingesting terabytes per day. This guide distills the hard-won lessons from those production deployments into a comprehensive resource that goes far beyond the "hello world" tutorials that dominate serverless content.
Serverless is not a universal replacement for containers or virtual machines. It is a specific architectural paradigm with profound advantages in certain scenarios and real limitations in others. The organizations that succeed with serverless are the ones that understand where it excels, where it struggles, and how to architect around its constraints. This guide will give you that understanding.
AWS global platform as of 2025
Lambda Functions Invoked Daily
We will cover the complete serverless landscape: decision frameworks for choosing serverless versus containers, Lambda runtime optimization down to the millisecond level, cold start analysis across every supported language, Provisioned Concurrency configuration, event source integration patterns, Step Functions orchestration for complex workflows, DynamoDB single-table design, observability with X-Ray and PowerTools, cost modeling that actually reflects production reality, security patterns for zero-trust serverless, migration strategies from ECS and EKS, and real-world architecture patterns including CQRS, saga, and fan-out.
Serverless vs. Containers: The Decision Framework
The first question every engineering team faces is not "how do I build serverless" but "should I build serverless." This is a nuanced decision that depends on workload characteristics, team expertise, operational maturity, and cost structure. I have seen too many teams adopt serverless because it is trendy, only to discover that their specific workload pattern is a terrible fit. Equally, I have seen container-bound teams leave massive efficiency gains on the table by not adopting serverless for workloads where it is clearly superior.
Choose Serverless When vs Choose Containers When
Choose Serverless When
Choose Containers When
The decision matrix is not binary. Most production architectures I build are hybrid. API endpoints that handle bursty HTTP traffic run on Lambda behind API Gateway. Long-running data processing jobs run on ECS Fargate. WebSocket connections live on ECS with Application Load Balancer. The art is knowing which workload belongs where.
Workload Characteristics That Favor Serverless
The ideal serverless workload has three properties: it is event-driven, it completes within minutes, and it scales to zero during idle periods. Event-driven APIs, webhook processors, file transformers triggered by S3 uploads, queue consumers processing SQS messages, and scheduled batch jobs that run periodically all fit this pattern perfectly.
The economic argument is strongest when your workload has significant idle time. A Lambda function that handles 1 million requests per month with an average duration of 200ms costs roughly 4 dollars in compute. The equivalent ECS Fargate task running 24/7 to handle the same traffic costs over 30 dollars. That gap widens further when you factor in the operational overhead of maintaining container infrastructure, updating base images, managing scaling policies, and responding to health check failures.
| workload | serverless | containers |
|---|---|---|
| API (1M req/mo, 200ms) | 4.2 | 32.85 |
| Queue Processor (5M msgs/mo) | 12.5 | 65.7 |
| Scheduled Job (hourly, 30s) | 0.85 | 32.85 |
| File Processor (100K/mo) | 3.4 | 32.85 |
| Stream Processor (constant) | 89.5 | 65.7 |
Notice the last row. When traffic is constant and high-throughput, containers become more cost-effective than serverless. This is the crossover point that every team needs to identify for their specific workload. The general rule I follow: if your function runs at sustained concurrency above 50 for more than 18 hours per day, you should evaluate whether containers would be cheaper.
Workload Characteristics That Favor Containers
Containers win for long-running processes, workloads requiring persistent connections, applications with large binary dependencies that cause slow cold starts, and any system that benefits from in-memory caching at the instance level. WebSocket servers, gRPC services with persistent channels, ML model servers that load multi-gigabyte models into memory, and database connection-heavy applications often perform better and cost less on containers.
For teams considering Kubernetes for advanced orchestration, the container path also provides more granular control over networking, resource allocation, and deployment strategies. But that control comes with significant operational complexity.
Lambda Runtime Optimization
Lambda performance optimization is a discipline unto itself. Every millisecond you shave off execution time reduces cost and improves user experience. I approach Lambda optimization in four layers: runtime selection, memory configuration, code optimization, and initialization optimization.
Runtime Performance by Language
Not all Lambda runtimes are created equal. The choice of language has a measurable impact on cold start latency, warm execution speed, and memory efficiency. I have benchmarked every supported runtime extensively.
| runtime | coldStart | warmExec |
|---|---|---|
| Rust (custom) | 12 | 1.2 |
| Go | 35 | 2.8 |
| C# (.NET 8 AOT) | 85 | 3.5 |
| Java 21 (SnapStart) | 120 | 4.2 |
| Node.js 20 | 145 | 5.8 |
| Python 3.12 | 180 | 8.5 |
| Java 21 (standard) | 3200 | 4 |
The data tells a clear story. Rust and Go deliver the fastest cold starts and warmest execution times, but the ecosystem for writing Lambda functions in these languages is less mature than Node.js or Python. For most teams, I recommend Node.js with TypeScript for API-oriented workloads and Python for data processing. If cold starts are a critical concern and your team has the capability, Rust with the cargo-lambda toolchain is transformative.
Java deserves special attention. Standard Java Lambda cold starts are notoriously slow, often exceeding 3 seconds. AWS SnapStart changes the equation dramatically by taking a snapshot of the initialized JVM and restoring it on cold start, bringing Java cold starts down to the 100-200ms range. If your organization is a Java shop, SnapStart makes serverless viable where it previously was not.
Memory Configuration and CPU Allocation
Lambda allocates CPU power proportional to memory. At 128 MB, you get a fraction of a vCPU. At 1,769 MB, you get exactly one full vCPU. At 10,240 MB (the maximum), you get roughly six vCPUs. This means increasing memory does not just give you more RAM, it gives you more compute. For CPU-bound workloads, increasing memory can actually reduce cost because the function finishes faster.
| memoryMB | durationMs | costPer1M |
|---|---|---|
| 128 | 3200 | 6.67 |
| 256 | 1650 | 6.81 |
| 512 | 850 | 7.02 |
| 1024 | 440 | 7.27 |
| 1769 | 260 | 7.42 |
| 2048 | 250 | 8.26 |
| 3072 | 245 | 12.14 |
The sweet spot for most workloads is between 512 MB and 1,769 MB. Below 512 MB, functions are CPU-starved and run slowly. Above 1,769 MB, you are paying for additional vCPUs that most single-threaded workloads cannot utilize. The exception is functions that genuinely need parallel processing, such as image manipulation, PDF generation, or data transformation with worker threads.
I use AWS Lambda Power Tuning, an open-source Step Functions state machine, to find the optimal memory setting for every function I deploy. It runs your function at multiple memory configurations and plots the cost-performance curve. Automate this as part of your CI/CD pipeline and you will never overpay for Lambda again.
// Lambda Power Tuning configuration
const powerTuningInput = {
lambdaARN: 'arn:aws:lambda:us-east-1:123456789:function:my-api',
powerValues: [128, 256, 512, 1024, 1769, 2048, 3072],
num: 50,
payload: JSON.stringify({ path: '/api/users', httpMethod: 'GET' }),
parallelInvocation: true,
strategy: 'cost', // or 'speed' or 'balanced'
}
Code-Level Optimization Patterns
Beyond configuration, the code itself determines Lambda performance. Here are the patterns I enforce across every serverless project.
Lazy initialization is critical. Never import modules or initialize SDK clients at the top level unless they are needed on every invocation. Use dynamic imports or conditional initialization to keep the cold start path lean.
import type { DynamoDBClient } from '@aws-sdk/client-dynamodb'
let dynamoClient: DynamoDBClient | undefined
function getDynamoClient(): DynamoDBClient {
if (!dynamoClient) {
// Only import and initialize when first needed
const { DynamoDBClient } = require('@aws-sdk/client-dynamodb')
dynamoClient = new DynamoDBClient({
region: process.env.AWS_REGION,
maxAttempts: 3,
})
}
return dynamoClient
}
export const handler = async (event: APIGatewayProxyEvent) => {
// Client reused across warm invocations
const client = getDynamoClient()
// ... handler logic
}
Connection reuse is the single most impactful optimization for functions that call other AWS services or external APIs. Enable HTTP keep-alive for the AWS SDK and reuse connections across invocations.
import { NodeHttpHandler } from '@smithy/node-http-handler'
import https from 'https'
const agent = new https.Agent({
keepAlive: true,
maxSockets: 50,
keepAliveMsecs: 1000,
})
const httpHandler = new NodeHttpHandler({
httpsAgent: agent,
connectionTimeout: 3000,
socketTimeout: 3000,
})
Bundle size matters. Every byte of your deployment package affects cold start time. Use esbuild or similar bundlers to tree-shake unused code. Avoid importing entire SDKs when you only need one service client. Use the modular AWS SDK v3 instead of the monolithic v2.
Cold Start Analysis and Mitigation
Cold starts are the most discussed limitation of serverless architecture, and with good reason. A cold start occurs when Lambda must create a new execution environment for your function, which involves downloading your deployment package, initializing the runtime, and executing your initialization code. For latency-sensitive applications, cold starts can be the difference between a viable serverless architecture and one that fails to meet SLAs.
Cold Start Probability and Duration
Cold start frequency depends on traffic patterns. Functions that receive steady traffic rarely experience cold starts because Lambda keeps execution environments warm for approximately 5 to 15 minutes after the last invocation. Functions with bursty traffic or long idle periods trigger cold starts more frequently.
| Name | Value |
|---|---|
| Warm Invocations (typical API) | 95.2 |
| Cold Starts (initial burst) | 3.1 |
| Cold Starts (scale-up events) | 1.2 |
| Cold Starts (idle timeout) | 0.5 |
For a typical API workload processing 10,000 requests per hour, roughly 95 percent of invocations will be warm. But that remaining 5 percent hits real users, and if your cold start adds 2 seconds of latency, those users will notice.
Cold Start by Language and Package Size
Package size is the factor that most teams underestimate. A minimal Node.js function with no dependencies cold starts in about 80ms. The same function bundled with the full AWS SDK v2 (80 MB) cold starts in 500ms or more. A Java function with Spring Boot can exceed 10 seconds.
| scenario | duration |
|---|---|
| Node.js minimal (1 MB) | 80 |
| Node.js + SDK v3 (5 MB) | 145 |
| Node.js + SDK v2 (80 MB) | 520 |
| Python minimal (1 MB) | 110 |
| Python + pandas/numpy (60 MB) | 890 |
| Java minimal (2 MB) | 850 |
| Java + Spring Boot (45 MB) | 6200 |
| Rust custom runtime (3 MB) | 12 |
Provisioned Concurrency
For workloads that cannot tolerate cold starts, Provisioned Concurrency pre-initializes a specified number of execution environments that are always ready to handle requests. This eliminates cold starts entirely for those pre-warmed environments but comes at a cost: you pay for the provisioned environments whether they are used or not.
# serverless.yml - Provisioned Concurrency configuration
functions:
api:
handler: src/handlers/api.handler
runtime: nodejs20.x
memorySize: 1024
timeout: 29
provisionedConcurrency: 10
events:
- httpApi:
path: /api/{proxy+}
method: ANY
# Use Application Auto Scaling for dynamic provisioned concurrency
apiScaling:
Type: AWS::ApplicationAutoScaling::ScalableTarget
Properties:
MaxCapacity: 100
MinCapacity: 5
ResourceId: !Sub function:${ApiLambdaFunction}:prod
ScalableDimension: lambda:function:ProvisionedConcurrency
ServiceNamespace: lambda
The cost calculation for Provisioned Concurrency is straightforward. At 1,024 MB memory in us-east-1, provisioned concurrency costs approximately 0.000004646 dollars per GB-second. Ten provisioned environments running 24/7 cost roughly 3.50 dollars per day or 106 dollars per month. Compare that against the business impact of cold starts on your user experience to determine if it is worth it.
10 environments at 1024 MB, us-east-1
Provisioned Concurrency Cost
I recommend Provisioned Concurrency selectively: enable it for customer-facing API functions where latency matters, and skip it for background processing functions where an extra second of cold start is irrelevant.
Event Sources and Integration Patterns
Lambda is fundamentally an event-driven compute service. Understanding the different event source integrations and their characteristics is essential for building robust serverless architectures. Each event source has different invocation semantics, retry behavior, error handling characteristics, and scaling properties.
Event Source Landscape
| source | maxBatchSize | maxConcurrency |
|---|---|---|
| API Gateway (REST) | 1 | 10000 |
| API Gateway (HTTP) | 1 | 10000 |
| SQS Standard | 10000 | 1250 |
| SQS FIFO | 10000 | 300 |
| EventBridge | 1 | 10000 |
| S3 Events | 1 | 1000 |
| DynamoDB Streams | 10000 | 10 |
| Kinesis | 10000 | 10 |
API Gateway Integration
API Gateway is the most common entry point for serverless APIs. I strongly recommend HTTP APIs over REST APIs for new projects. HTTP APIs are cheaper (1.00 dollar per million requests versus 3.50 dollars), faster (lower latency overhead), and support JWT authorizers natively. REST APIs are only necessary if you need request validation, usage plans, API keys, or WAF integration.
// Handler pattern for API Gateway HTTP API
import { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from 'aws-lambda'
interface ApiResponse {
statusCode: number
body: string
headers?: Record<string, string>
}
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET,POST,PUT,DELETE,OPTIONS',
'Content-Type': 'application/json',
}
export const handler = async (
event: APIGatewayProxyEventV2
): Promise<APIGatewayProxyResultV2> => {
try {
const { routeKey, pathParameters, body } = event
switch (routeKey) {
case 'GET /users/{id}':
return await getUser(pathParameters?.id!)
case 'POST /users':
return await createUser(JSON.parse(body || '{}'))
case 'PUT /users/{id}':
return await updateUser(pathParameters?.id!, JSON.parse(body || '{}'))
default:
return { statusCode: 404, body: JSON.stringify({ error: 'Not found' }) }
}
} catch (error) {
console.error('Unhandled error:', error)
return {
statusCode: 500,
headers: corsHeaders,
body: JSON.stringify({ error: 'Internal server error' }),
}
}
}
SQS Integration for Async Processing
SQS is my default choice for decoupling synchronous API calls from asynchronous processing. The Lambda SQS integration handles polling, batching, and scaling automatically. The key configuration decisions are batch size, visibility timeout, and dead-letter queue setup.
// SQS batch processor with partial failure reporting
import { SQSBatchResponse, SQSEvent, SQSRecord } from 'aws-lambda'
export const handler = async (event: SQSEvent): Promise<SQSBatchResponse> => {
const batchItemFailures: { itemIdentifier: string }[] = []
const processRecord = async (record: SQSRecord) => {
try {
const payload = JSON.parse(record.body)
await processOrder(payload)
} catch (error) {
console.error(`Failed to process ${record.messageId}:`, error)
batchItemFailures.push({ itemIdentifier: record.messageId })
}
}
// Process records concurrently within the batch
await Promise.allSettled(event.Records.map(processRecord))
// Return partial batch failure - only failed messages go back to queue
return { batchItemFailures }
}
The ReportBatchItemFailures feature is critical. Without it, a single failed message in a batch of 10 causes all 10 messages to return to the queue. With partial batch failure reporting, only the failed messages retry. This is one of the most important Lambda features that teams consistently overlook.
EventBridge for Event-Driven Architecture
EventBridge is the backbone of sophisticated event-driven architectures. It decouples producers from consumers, supports content-based routing, and integrates with over 100 AWS services as targets. I use EventBridge as the central nervous system for every serverless application I build.
// Publishing a domain event to EventBridge
import {
EventBridgeClient,
PutEventsCommand,
} from '@aws-sdk/client-eventbridge'
const ebClient = new EventBridgeClient({ region: 'us-east-1' })
async function publishOrderEvent(order: Order, eventType: string) {
await ebClient.send(
new PutEventsCommand({
Entries: [
{
Source: 'orders.service',
DetailType: eventType,
Detail: JSON.stringify({
orderId: order.id,
customerId: order.customerId,
total: order.total,
items: order.items,
timestamp: new Date().toISOString(),
}),
EventBusName: 'commerce-events',
},
],
})
)
}
For deeper coverage of event-driven patterns including event sourcing and CQRS, see my guide on event sourcing patterns for audit-first systems.
Step Functions for Workflow Orchestration
Step Functions is the service that transforms serverless from isolated functions into coordinated applications. Any serverless workflow that involves multiple steps, conditional logic, error handling, retries, or parallel processing should use Step Functions rather than chaining Lambda invocations directly.
Direct Lambda-to-Lambda invocation is an antipattern. It creates tight coupling, makes error handling fragile, costs more due to the invoking function waiting while the invoked function executes, and creates debugging nightmares. Step Functions solves all of these problems.
Express vs. Standard Workflows
Step Functions offers two workflow types with fundamentally different characteristics.
Standard Workflows vs Express Workflows
Standard Workflows
Express Workflows
For API-driven orchestration where the workflow completes in seconds, Express Workflows can be 10 to 100 times cheaper than Standard Workflows. I use Express Workflows for API orchestration patterns and Standard Workflows for order processing, ETL pipelines, and any workflow that needs guaranteed exactly-once execution.
Real-World Step Functions Pattern: Order Processing Saga
The saga pattern is essential for managing distributed transactions across microservices. Step Functions is the ideal implementation vehicle because it provides built-in compensation logic through catch blocks and maintains a complete execution history.
# Step Functions ASL for order processing saga
Comment: Order Processing Saga with Compensating Transactions
StartAt: ValidateOrder
States:
ValidateOrder:
Type: Task
Resource: arn:aws:lambda:us-east-1:123456789:function:validate-order
Next: ReserveInventory
Catch:
- ErrorEquals: ['ValidationError']
Next: OrderFailed
ReserveInventory:
Type: Task
Resource: arn:aws:lambda:us-east-1:123456789:function:reserve-inventory
Next: ProcessPayment
Catch:
- ErrorEquals: ['InsufficientInventory']
Next: OrderFailed
ProcessPayment:
Type: Task
Resource: arn:aws:lambda:us-east-1:123456789:function:process-payment
Next: ConfirmOrder
Catch:
- ErrorEquals: ['PaymentFailed']
Next: ReleaseInventory
ReleaseInventory:
Type: Task
Resource: arn:aws:lambda:us-east-1:123456789:function:release-inventory
Next: OrderFailed
ConfirmOrder:
Type: Task
Resource: arn:aws:lambda:us-east-1:123456789:function:confirm-order
Next: NotifyCustomer
NotifyCustomer:
Type: Task
Resource: arn:aws:lambda:us-east-1:123456789:function:notify-customer
End: true
OrderFailed:
Type: Task
Resource: arn:aws:lambda:us-east-1:123456789:function:handle-failure
End: true
This pattern ensures that if payment processing fails, the inventory reservation is automatically released. Without Step Functions, you would need to implement this compensation logic manually across multiple Lambda functions with complex error propagation, and failures in the compensation logic itself would leave the system in an inconsistent state.
DynamoDB Single-Table Design for Serverless
DynamoDB is the database of serverless. Its pay-per-request pricing, single-digit millisecond latency, and seamless scaling make it the natural data store for Lambda functions. But DynamoDB is not a relational database, and teams that approach it with a relational mindset will struggle. Single-table design is the pattern that unlocks DynamoDB's full potential.
Single-Table Design Principles
In single-table design, you store multiple entity types in a single DynamoDB table and use composite key patterns to support multiple access patterns. This eliminates the need for joins (which DynamoDB does not support) and allows you to retrieve all related data in a single query.
// Single-table design for an e-commerce domain
// PK and SK patterns for multiple entities in one table
interface DynamoDBItem {
PK: string // Partition key
SK: string // Sort key
GSI1PK?: string // Global Secondary Index 1
GSI1SK?: string
entityType: string
[key: string]: unknown
}
// Customer: PK=CUSTOMER#123, SK=PROFILE
// Order: PK=CUSTOMER#123, SK=ORDER#2024-01-15#abc
// Product: PK=PRODUCT#xyz, SK=METADATA
// OrderItem: PK=ORDER#abc, SK=ITEM#product-xyz
// GSI1: GSI1PK=ORDER#abc, GSI1SK=CUSTOMER#123 (order lookup)
const accessPatterns = {
// Get customer profile
getCustomer: { PK: 'CUSTOMER#123', SK: 'PROFILE' },
// Get all orders for a customer (sorted by date)
getCustomerOrders: {
PK: 'CUSTOMER#123',
SK: { begins_with: 'ORDER#' },
},
// Get order with all items
getOrderWithItems: {
PK: 'ORDER#abc',
SK: { begins_with: 'ITEM#' },
},
// Get customer profile + recent orders in one query
getCustomerDashboard: {
PK: 'CUSTOMER#123',
SK: { between: ['ORDER#', 'ORDER$'] }, // alphabetically after all ORDER# keys
},
}
DynamoDB Cost Model for Serverless
DynamoDB offers two capacity modes: on-demand and provisioned. On-demand pricing is 1.25 dollars per million write request units and 0.25 dollars per million read request units. Provisioned mode is roughly 60 percent cheaper but requires capacity planning.
| pattern | onDemandMonthly | provisionedMonthly |
|---|---|---|
| Read-heavy API (90/10) | 45 | 18 |
| Write-heavy ingest (20/80) | 210 | 85 |
| Balanced CRUD (50/50) | 125 | 52 |
| Bursty analytics (spikes) | 85 | 120 |
| Event store (append-only) | 165 | 68 |
The key insight: on-demand mode is cheaper for bursty workloads because provisioned mode requires you to provision for peak capacity. For steady-state workloads, provisioned mode with auto-scaling saves 50 to 65 percent. I start every project on on-demand mode and switch to provisioned after I have enough traffic data to set sensible auto-scaling targets.
Serverless Observability
Observability in serverless is fundamentally different from traditional application monitoring. You cannot SSH into a Lambda function. There is no persistent process to attach a debugger to. You do not have access to system-level metrics like CPU or memory utilization in the traditional sense. Instead, serverless observability relies on structured logging, distributed tracing, and custom metrics.
The Three Pillars for Serverless
Lambda PowerTools for TypeScript
AWS Lambda PowerTools is the library that makes serverless observability practical. It provides structured logging, tracing integration, custom metrics emission, and idempotency support in a single, well-designed package. I include it in every Lambda project.
import { Logger } from '@aws-lambda-powertools/logger'
import { Tracer } from '@aws-lambda-powertools/tracer'
import { Metrics, MetricUnit } from '@aws-lambda-powertools/metrics'
import middy from '@middy/core'
const logger = new Logger({ serviceName: 'order-service' })
const tracer = new Tracer({ serviceName: 'order-service' })
const metrics = new Metrics({
namespace: 'Commerce',
serviceName: 'order-service',
})
const processOrder = async (event: APIGatewayProxyEventV2) => {
// Structured logging with correlation IDs
logger.appendKeys({
orderId: event.pathParameters?.id,
correlationId: event.headers['x-correlation-id'],
})
logger.info('Processing order', { action: 'processOrder' })
// Custom X-Ray subsegment for database call
const subsegment = tracer.getSegment()?.addNewSubsegment('DynamoDB Query')
try {
const order = await getOrder(event.pathParameters?.id!)
subsegment?.close()
// Emit custom metric
metrics.addMetric('OrderProcessed', MetricUnit.Count, 1)
metrics.addMetric('OrderValue', MetricUnit.None, order.total)
return {
statusCode: 200,
body: JSON.stringify(order),
}
} catch (error) {
subsegment?.addError(error as Error)
subsegment?.close()
metrics.addMetric('OrderProcessingError', MetricUnit.Count, 1)
logger.error('Order processing failed', { error })
throw error
}
}
// Middy middleware wraps PowerTools cleanly
export const handler = middy(processOrder)
.use(captureLambdaHandler(tracer))
.use(injectLambdaContext(logger))
.use(logMetrics(metrics))
For organizations that need more comprehensive observability beyond what CloudWatch and X-Ray provide, dedicated observability platforms provide deeper insights. See my analysis of observability engineering at enterprise scale for a comparison of approaches.
CloudWatch Embedded Metric Format
CloudWatch Embedded Metric Format (EMF) allows you to embed custom metrics directly within structured log entries. This is more cost-effective than using the PutMetricData API because you do not pay separately for the metric data points. The metrics are extracted from your logs automatically.
// CloudWatch EMF manual emission
const emitMetric = (metricName: string, value: number, unit: string) => {
console.log(
JSON.stringify({
_aws: {
Timestamp: Date.now(),
CloudWatchMetrics: [
{
Namespace: 'ServerlessApp',
Dimensions: [['FunctionName', 'Environment']],
Metrics: [{ Name: metricName, Unit: unit }],
},
],
},
FunctionName: process.env.AWS_LAMBDA_FUNCTION_NAME,
Environment: process.env.STAGE,
[metricName]: value,
})
)
}
Cost Modeling and Optimization
Understanding serverless cost is essential because the pricing model is fundamentally different from compute-hours-based pricing. You pay for invocations, duration, memory, data transfer, and the various services in your architecture. I have seen teams build architectures that should cost 50 dollars per month but end up paying 5,000 dollars because they missed a key cost driver.
Lambda Cost Breakdown
Lambda pricing has three components: request charges (0.20 dollars per million invocations), duration charges (0.0000166667 dollars per GB-second), and optional Provisioned Concurrency charges. For most workloads, duration is the dominant cost factor.
| Name | Value |
|---|---|
| Lambda Duration | 42 |
| API Gateway | 18 |
| DynamoDB | 22 |
| Data Transfer | 8 |
| CloudWatch Logs | 6 |
| Other (SQS, SNS, S3) | 4 |
CloudWatch Logs is the cost that consistently surprises teams. Every console.log in your Lambda function writes to CloudWatch Logs, and at high volume, log ingestion charges add up. At 0.50 dollars per GB of log data ingested, a verbose function processing millions of events can generate hundreds of dollars in log costs alone. Set appropriate log levels, use structured logging to minimize payload size, and configure log retention policies aggressively.
Cost Optimization Strategies
The most effective serverless cost optimizations, ranked by typical impact:
| strategy | savingsPercent |
|---|---|
| Right-size memory | 35 |
| Reduce log verbosity | 22 |
| Use ARM64 (Graviton) | 20 |
| Batch processing | 18 |
| Cache with ElastiCache | 15 |
| HTTP API vs REST API | 12 |
| DynamoDB provisioned mode | 10 |
ARM64 Graviton Lambda is the easiest win. Simply changing your function architecture from x86_64 to arm64 gives you 20 percent lower pricing and roughly 20 percent better performance for most workloads. If you are not running Lambda on Graviton, you are overpaying. For a detailed analysis of ARM64 benefits across AWS services, see my guide on Graviton3 cost optimization.
# Terraform - Lambda on Graviton (ARM64)
resource "aws_lambda_function" "api" {
function_name = "order-api"
runtime = "nodejs20.x"
architectures = ["arm64"] # 20% cheaper than x86_64
memory_size = 1024
timeout = 29
handler = "dist/handler.handler"
filename = data.archive_file.lambda.output_path
source_code_hash = data.archive_file.lambda.output_base64sha256
role = aws_iam_role.lambda_exec.arn
environment {
variables = {
TABLE_NAME = aws_dynamodb_table.orders.name
STAGE = var.environment
LOG_LEVEL = var.environment == "production" ? "WARN" : "DEBUG"
}
}
tracing_config {
mode = "Active"
}
}
Monthly Cost Projection Model
Here is a realistic cost model for a serverless API handling moderate traffic. This is the model I use during architecture reviews to set accurate budget expectations.
10M requests/mo, 200ms avg duration, 1024 MB
Monthly Cost Estimate
That 285-dollar monthly total breaks down across services. Compare this to the equivalent ECS Fargate deployment, which would require at minimum two tasks running 24/7 for high availability, costing approximately 120 dollars just for compute before adding load balancer, service discovery, and monitoring costs. At moderate scale, serverless and containers are roughly cost-equivalent. The serverless advantage grows at lower scale (due to scale-to-zero) and diminishes at higher scale (due to per-invocation pricing).
Security Patterns for Serverless
Serverless shifts security responsibilities but does not eliminate them. You are no longer responsible for OS patching, runtime updates, or network segmentation at the host level. But you are fully responsible for IAM policies, application-level security, data encryption, and input validation. The principle of least privilege becomes even more critical in serverless because each function has its own IAM role.
Least Privilege IAM
Every Lambda function should have its own IAM role with the minimum permissions required for that specific function. I never share IAM roles across functions. A function that reads from DynamoDB should not have write permissions. A function that processes SQS messages should not have access to S3.
# Terraform - Least privilege IAM for a specific Lambda function
resource "aws_iam_role" "get_order_lambda" {
name = "get-order-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" "get_order_policy" {
name = "get-order-policy"
role = aws_iam_role.get_order_lambda.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = ["dynamodb:GetItem", "dynamodb:Query"]
Resource = [
aws_dynamodb_table.orders.arn,
"${aws_dynamodb_table.orders.arn}/index/*"
]
},
{
Effect = "Allow"
Action = [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents"
]
Resource = "arn:aws:logs:*:*:*"
},
{
Effect = "Allow"
Action = ["xray:PutTraceSegments", "xray:PutTelemetryRecords"]
Resource = "*"
}
]
})
}
API Security Layers
For zero-trust API security, I layer multiple security mechanisms. JWT validation at the API Gateway level prevents unauthorized access before Lambda is even invoked. Input validation within the function prevents injection attacks. Rate limiting at the API Gateway level prevents abuse and cost explosion.
// Input validation middleware for Lambda
import { z } from 'zod'
const CreateOrderSchema = z.object({
customerId: z.string().uuid(),
items: z
.array(
z.object({
productId: z.string().uuid(),
quantity: z.number().int().positive().max(100),
price: z.number().positive().max(999999),
})
)
.min(1)
.max(50),
shippingAddress: z.object({
street: z.string().min(1).max(200),
city: z.string().min(1).max(100),
state: z.string().length(2),
zip: z.string().regex(/^\d{5}(-\d{4})?$/),
}),
})
function validateInput<T>(schema: z.ZodSchema<T>, data: unknown): T {
const result = schema.safeParse(data)
if (!result.success) {
throw new ValidationError(
'Invalid input',
result.error.issues.map(i => `${i.path.join('.')}: ${i.message}`)
)
}
return result.data
}
Secrets Management
Never store secrets in environment variables as plaintext. Use AWS Secrets Manager or SSM Parameter Store with encryption, and cache the values within the Lambda execution environment to avoid fetching them on every invocation.
import {
SecretsManagerClient,
GetSecretValueCommand,
} from '@aws-sdk/client-secrets-manager'
const smClient = new SecretsManagerClient({ region: 'us-east-1' })
const secretCache = new Map<string, { value: string; expiry: number }>()
const CACHE_TTL_MS = 300_000 // 5 minutes
async function getSecret(secretId: string): Promise<string> {
const cached = secretCache.get(secretId)
if (cached && cached.expiry > Date.now()) {
return cached.value
}
const response = await smClient.send(
new GetSecretValueCommand({ SecretId: secretId })
)
const value = response.SecretString!
secretCache.set(secretId, { value, expiry: Date.now() + CACHE_TTL_MS })
return value
}
Migration from ECS and EKS to Serverless
Migrating from containers to serverless is not a lift-and-shift operation. It requires rearchitecting your application from a long-running process model to an event-driven, stateless function model. I have led dozens of these migrations and the pattern that works is the strangler fig approach: migrate one API route or one processing pipeline at a time, not the entire application at once.
Migration Timeline
Assessment and Planning
Inventory all ECS/EKS services. Identify stateless APIs, queue consumers, and scheduled jobs as migration candidates. Map dependencies and data flows.
Infrastructure Setup
Deploy serverless infrastructure: API Gateway, Lambda functions, DynamoDB tables, SQS queues. Set up CI/CD for serverless deployments with SAM or CDK.
First Service Migration
Migrate the simplest stateless API service. Run in parallel with the container version. Validate performance, cost, and correctness with traffic mirroring.
Expand Migration Scope
Migrate additional services one at a time. Address cold start issues, optimize memory settings, implement observability. Each migration follows the same validate-and-cutover pattern.
Decommission and Optimize
Decommission container infrastructure for migrated services. Optimize Lambda configurations based on production traffic data. Implement Provisioned Concurrency where needed.
What Not to Migrate
Not every container workload should become serverless. These patterns should stay on containers:
- Services maintaining persistent WebSocket connections
- Applications loading large ML models into memory (multi-GB)
- Long-running batch processes exceeding 15 minutes
- Services requiring more than 10 GB of memory
- Applications with complex multi-threaded processing
- Services that depend on local filesystem persistence
Strangler Fig Pattern Implementation
The strangler fig pattern works by routing traffic to either the legacy container service or the new Lambda function based on the API path. API Gateway makes this trivial with route-level configuration.
# Terraform - Strangler fig routing with API Gateway
resource "aws_apigatewayv2_api" "main" {
name = "commerce-api"
protocol_type = "HTTP"
}
# Migrated routes go to Lambda
resource "aws_apigatewayv2_integration" "lambda_orders" {
api_id = aws_apigatewayv2_api.main.id
integration_type = "AWS_PROXY"
integration_uri = aws_lambda_function.orders_api.invoke_arn
payload_format_version = "2.0"
}
resource "aws_apigatewayv2_route" "orders" {
api_id = aws_apigatewayv2_api.main.id
route_key = "ANY /orders/{proxy+}"
target = "integrations/${aws_apigatewayv2_integration.lambda_orders.id}"
}
# Legacy routes still go to ECS via VPC Link
resource "aws_apigatewayv2_integration" "ecs_legacy" {
api_id = aws_apigatewayv2_api.main.id
integration_type = "HTTP_PROXY"
integration_uri = aws_lb_listener.legacy.arn
integration_method = "ANY"
connection_type = "VPC_LINK"
connection_id = aws_apigatewayv2_vpc_link.main.id
}
resource "aws_apigatewayv2_route" "legacy" {
api_id = aws_apigatewayv2_api.main.id
route_key = "ANY /legacy/{proxy+}"
target = "integrations/${aws_apigatewayv2_integration.ecs_legacy.id}"
}
Real-World Architecture Patterns
The patterns in this section represent architectures I have deployed in production. They combine the building blocks discussed above into cohesive systems that handle real business requirements.
Pattern 1: CQRS with EventBridge and DynamoDB
Command Query Responsibility Segregation separates write operations from read operations, allowing each to be optimized independently. In a serverless context, this pattern uses separate Lambda functions for commands (writes) and queries (reads), with EventBridge propagating state changes from the write model to one or more read models.
// Command handler - writes to DynamoDB and publishes event
export const createOrderHandler = async (event: APIGatewayProxyEventV2) => {
const order = validateInput(CreateOrderSchema, JSON.parse(event.body!))
// Write to command store (DynamoDB)
await dynamoClient.send(
new PutCommand({
TableName: process.env.ORDERS_TABLE,
Item: {
PK: `ORDER#${order.id}`,
SK: 'METADATA',
...order,
status: 'PENDING',
createdAt: new Date().toISOString(),
version: 1,
},
ConditionExpression: 'attribute_not_exists(PK)',
})
)
// Publish domain event for read model projections
await ebClient.send(
new PutEventsCommand({
Entries: [
{
Source: 'orders.command',
DetailType: 'OrderCreated',
Detail: JSON.stringify(order),
EventBusName: 'commerce',
},
],
})
)
return { statusCode: 201, body: JSON.stringify({ orderId: order.id }) }
}
// Query handler - reads from optimized read model
export const getOrdersByCustomerHandler = async (
event: APIGatewayProxyEventV2
) => {
const customerId = event.pathParameters?.customerId
// Query denormalized read model for fast retrieval
const result = await dynamoClient.send(
new QueryCommand({
TableName: process.env.READ_MODEL_TABLE,
KeyConditionExpression: 'PK = :pk',
ExpressionAttributeValues: { ':pk': `CUSTOMER#${customerId}` },
ScanIndexForward: false,
Limit: 20,
})
)
return { statusCode: 200, body: JSON.stringify(result.Items) }
}
Pattern 2: Fan-Out Processing with SNS and SQS
The fan-out pattern distributes a single event to multiple independent consumers. This is ideal for scenarios where a single business event triggers multiple downstream processes: sending a confirmation email, updating analytics, notifying a warehouse, and updating a search index.
// Fan-out architecture: SNS topic fans out to multiple SQS queues
// Each queue has its own Lambda consumer
// Email notification consumer
export const emailHandler = async (event: SQSEvent) => {
for (const record of event.Records) {
const snsMessage = JSON.parse(record.body)
const order = JSON.parse(snsMessage.Message)
await sendOrderConfirmationEmail(order)
}
}
// Analytics consumer
export const analyticsHandler = async (event: SQSEvent) => {
const events = event.Records.map(record => {
const snsMessage = JSON.parse(record.body)
return JSON.parse(snsMessage.Message)
})
await batchWriteToAnalyticsPipeline(events)
}
// Search index consumer
export const searchIndexHandler = async (event: SQSEvent) => {
for (const record of event.Records) {
const snsMessage = JSON.parse(record.body)
const order = JSON.parse(snsMessage.Message)
await indexOrderInOpenSearch(order)
}
}
Pattern 3: Real-Time Stream Processing
For high-volume event streams, Kinesis Data Streams with Lambda provides real-time processing with ordering guarantees within each shard. This pattern is ideal for IoT data ingestion, clickstream analytics, and financial transaction processing.
// Kinesis stream processor with batch aggregation
import { KinesisStreamEvent } from 'aws-lambda'
interface SensorReading {
deviceId: string
temperature: number
humidity: number
timestamp: string
}
export const handler = async (event: KinesisStreamEvent) => {
const readings: SensorReading[] = event.Records.map(record => {
const payload = Buffer.from(record.kinesis.data, 'base64').toString()
return JSON.parse(payload)
})
// Aggregate readings by device
const deviceAggregates = new Map<string, SensorReading[]>()
for (const reading of readings) {
const existing = deviceAggregates.get(reading.deviceId) || []
existing.push(reading)
deviceAggregates.set(reading.deviceId, existing)
}
// Write aggregated results to DynamoDB in batch
const writeRequests = Array.from(deviceAggregates.entries()).map(
([deviceId, deviceReadings]) => ({
PutRequest: {
Item: {
PK: `DEVICE#${deviceId}`,
SK: `AGG#${new Date().toISOString()}`,
avgTemp:
deviceReadings.reduce((s, r) => s + r.temperature, 0) /
deviceReadings.length,
avgHumidity:
deviceReadings.reduce((s, r) => s + r.humidity, 0) /
deviceReadings.length,
sampleCount: deviceReadings.length,
},
},
})
)
await batchWrite(process.env.TABLE_NAME!, writeRequests)
}
Infrastructure as Code for Serverless
Every serverless deployment I manage is defined entirely in code. For AWS serverless, I use a combination of the AWS CDK for complex architectures and Terraform for multi-cloud or infrastructure-heavy deployments. The SAM (Serverless Application Model) CLI remains useful for local development and testing.
Complete Terraform Module
Here is a production-grade Terraform module that deploys a serverless API with all the supporting infrastructure.
# Terraform module: serverless API with DynamoDB, SQS, and observability
terraform {
required_providers {
aws = { source = "hashicorp/aws", version = "~> 5.0" }
}
}
variable "environment" { type = string }
variable "service_name" { type = string }
# DynamoDB table with single-table design
resource "aws_dynamodb_table" "main" {
name = "${var.service_name}-${var.environment}"
billing_mode = "PAY_PER_REQUEST"
hash_key = "PK"
range_key = "SK"
attribute {
name = "PK"
type = "S"
}
attribute {
name = "SK"
type = "S"
}
attribute {
name = "GSI1PK"
type = "S"
}
attribute {
name = "GSI1SK"
type = "S"
}
global_secondary_index {
name = "GSI1"
hash_key = "GSI1PK"
range_key = "GSI1SK"
projection_type = "ALL"
}
point_in_time_recovery { enabled = true }
server_side_encryption {
enabled = true
kms_key_arn = aws_kms_key.dynamo.arn
}
tags = {
Environment = var.environment
Service = var.service_name
}
}
# Dead-letter queue for failed processing
resource "aws_sqs_queue" "dlq" {
name = "${var.service_name}-dlq-${var.environment}"
message_retention_seconds = 1209600 # 14 days
kms_master_key_id = aws_kms_key.sqs.id
}
# Processing queue with DLQ
resource "aws_sqs_queue" "processing" {
name = "${var.service_name}-processing-${var.environment}"
visibility_timeout_seconds = 300
kms_master_key_id = aws_kms_key.sqs.id
redrive_policy = jsonencode({
deadLetterTargetArn = aws_sqs_queue.dlq.arn
maxReceiveCount = 3
})
}
# CloudWatch alarms
resource "aws_cloudwatch_metric_alarm" "lambda_errors" {
alarm_name = "${var.service_name}-errors-${var.environment}"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 2
metric_name = "Errors"
namespace = "AWS/Lambda"
period = 300
statistic = "Sum"
threshold = 5
alarm_actions = [aws_sns_topic.alerts.arn]
dimensions = {
FunctionName = aws_lambda_function.api.function_name
}
}
resource "aws_cloudwatch_metric_alarm" "dlq_messages" {
alarm_name = "${var.service_name}-dlq-${var.environment}"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 1
metric_name = "ApproximateNumberOfMessagesVisible"
namespace = "AWS/SQS"
period = 300
statistic = "Sum"
threshold = 0
alarm_actions = [aws_sns_topic.alerts.arn]
dimensions = {
QueueName = aws_sqs_queue.dlq.name
}
}
For teams managing cloud cost across serverless and other services, I recommend establishing a dedicated cost optimization practice that includes serverless-specific monitoring.
Common Antipatterns and How to Avoid Them
After eight years of serverless production deployments, these are the antipatterns I see most frequently. Each one has cost teams significant money, reliability, or both.
Antipattern 1: Lambda-to-Lambda Direct Invocation
Never invoke one Lambda function directly from another using the AWS SDK. This creates tight coupling, doubles your execution cost (both functions run simultaneously), and makes error handling brittle. Use SQS, EventBridge, or Step Functions instead.
Antipattern 2: Monolithic Lambda Functions
A single Lambda function that handles all API routes is an antipattern. Each route should have its own function with its own IAM role, memory configuration, and timeout. This enables granular scaling, precise IAM permissions, and independent deployment.
Antipattern 3: Ignoring Cold Start Impact
Teams that deploy Java Spring Boot applications to Lambda without SnapStart or Provisioned Concurrency are delivering 3 to 10 second cold starts to their users. Measure cold start impact on your P99 latency and address it explicitly.
Antipattern 4: Unbounded Concurrency
Lambda scales automatically, but that scaling can overwhelm downstream services. A Lambda function connected to an RDS database without connection pooling will exhaust the database connection limit within seconds during a traffic spike. Use RDS Proxy, DynamoDB, or implement client-side throttling.
Antipattern 5: Logging Everything at DEBUG Level
CloudWatch Logs charges per GB ingested. A function logging full request and response bodies at DEBUG level in production can generate more log cost than compute cost. Use environment-variable-controlled log levels and structured logging.
| Name | Value |
|---|---|
| Direct Lambda invocation | 28 |
| Monolithic functions | 22 |
| Ignoring cold starts | 20 |
| Unbounded concurrency | 15 |
| Excessive logging | 15 |
Scaling Characteristics and Limits
Understanding Lambda scaling behavior is critical for production reliability. Lambda does not scale infinitely or instantaneously. There are account-level and function-level concurrency limits, and burst scaling has specific constraints.
Account and Function Limits
Each AWS account has a default concurrent execution limit of 1,000 across all functions in a region. This is the single most common cause of Lambda throttling in production. Request a limit increase to 10,000 or higher before launching any significant workload. Individual functions can be assigned reserved concurrency to guarantee capacity and prevent one noisy function from starving others.
Per AWS account per region (must request increase)
Default Concurrency Limit
Burst Scaling Behavior
Lambda burst scaling adds 500 concurrent executions per minute in most regions (3,000 initial burst in us-east-1). If your function receives a sudden spike from 0 to 5,000 concurrent requests, Lambda will serve the first 3,000 immediately, then add 500 more per minute until it reaches your account limit. Requests that exceed available concurrency receive a 429 throttle response.
This burst behavior means that purely event-driven architectures must account for throttling during rapid scale-up events. SQS integration handles this gracefully because messages simply wait in the queue during throttling. Synchronous API Gateway invocations return 429 errors to clients, which is why Provisioned Concurrency matters for latency-sensitive APIs.
Testing Strategies for Serverless
Testing serverless applications requires a different approach than testing traditional applications. Unit tests work the same way, but integration and end-to-end testing require either local emulation or deployment to an ephemeral cloud environment.
Local Development with SAM CLI
AWS SAM CLI provides local Lambda invocation and API Gateway emulation. This works well for basic function testing but does not accurately simulate DynamoDB, SQS, or EventBridge integrations.
# Start local API Gateway emulation
sam local start-api --template template.yaml --env-vars env.json
# Invoke a single function with a test event
sam local invoke OrderFunction --event events/create-order.json
# Generate a test event from a template
sam local generate-event apigateway http-api-proxy \
--method POST \
--path /orders \
--body '{"customerId":"abc","items":[]}' > events/create-order.json
Integration Testing with Ephemeral Stacks
For true integration testing, I deploy ephemeral serverless stacks as part of the CI/CD pipeline. Each pull request gets its own stack with real DynamoDB tables, SQS queues, and Lambda functions. Tests run against the real AWS services, then the stack is destroyed.
// Integration test against ephemeral stack
describe('Order API Integration', () => {
const apiUrl = process.env.API_URL // Set by CI/CD from stack outputs
it('should create and retrieve an order', async () => {
const createResponse = await fetch(`${apiUrl}/orders`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
customerId: 'test-customer-123',
items: [{ productId: 'prod-1', quantity: 2, price: 29.99 }],
shippingAddress: {
street: '123 Test St',
city: 'Portland',
state: 'OR',
zip: '97201',
},
}),
})
expect(createResponse.status).toBe(201)
const { orderId } = await createResponse.json()
// Allow eventual consistency for read model
await new Promise(resolve => setTimeout(resolve, 2000))
const getResponse = await fetch(`${apiUrl}/orders/${orderId}`)
expect(getResponse.status).toBe(200)
const order = await getResponse.json()
expect(order.customerId).toBe('test-customer-123')
expect(order.status).toBe('PENDING')
})
})
The Serverless Maturity Model
Organizations adopt serverless through a predictable progression. Understanding where you are on this maturity curve helps prioritize the right investments.
Most teams I work with are at Level 2 or 3. The jump from Level 3 to Level 4 is where serverless becomes truly transformative, with shared event buses, standardized function templates, automated observability, and centralized cost governance. The organizations that reach Level 5 are running hundreds of serverless services across multiple accounts with automated compliance and centralized operational visibility.
Conclusion
Serverless architecture is not the future of all computing. It is the present reality for a specific and growing category of workloads where event-driven, scale-to-zero, pay-per-use characteristics align with business requirements. The organizations that succeed with serverless are the ones that approach it pragmatically: choosing it where it excels, avoiding it where it does not, and investing in the operational practices that make it reliable at scale.
The key takeaways from eight years of production serverless:
First, start with the decision framework. Not every workload belongs on Lambda. Constant high-throughput workloads, long-running processes, and stateful applications are often better served by containers. Bursty APIs, event processors, scheduled jobs, and file transformers are ideal for serverless.
Second, optimize aggressively. Right-size memory with Lambda Power Tuning, run on ARM64 Graviton, minimize bundle size, reuse connections, and control log verbosity. These optimizations compound to reduce costs by 40 to 60 percent compared to naive deployments.
Third, invest in observability from day one. Structured logging with PowerTools, distributed tracing with X-Ray, custom metrics with CloudWatch EMF, and alerting on error rates and DLQ depth are non-negotiable for production serverless.
Fourth, use Step Functions for any multi-step workflow. Direct Lambda-to-Lambda invocation is an antipattern. Step Functions provides retry logic, compensation patterns, execution history, and visual debugging that make complex workflows manageable.
Fifth, embrace DynamoDB single-table design. It requires a mindset shift from relational thinking, but the payoff in performance, cost, and operational simplicity is substantial. Model your access patterns first, then design your key schema.
The serverless ecosystem continues to evolve rapidly. SnapStart has made Java viable. Graviton Lambda has reduced costs by 20 percent. Lambda response streaming enables real-time data delivery. Each iteration makes serverless applicable to a broader set of workloads. The question is no longer whether to adopt serverless, but which parts of your architecture will benefit most from it.
