Before — EC2 ASG
Client
5ms
CloudFront (existing)
3ms
Application LB
3ms
EC2 ASG (t3.medium ×2)
5ms handler
Express Handler
18ms query
Backend Database
4ms
Serialize
Warm Latency
—
After — Serverless
Client
8ms
CloudFront (existing)
12ms
API Gateway HTTP
2ms warm
Lambda (ARM64)
5ms
DynamoDB + Backend DB
2ms
Serialize
Latency
—
EC2 Baseline
$0.00
Always-on billing at $0.0416/hr
Serverless
$0.00
Idle · no cost at rest · per-invocation only
💡 Why ~30% cheaper
▼
Before (EC2) — 100% baseline
After (Serverless) — ~70% of baseline
−30%
No always-on compute. The EC2 version paid for baseline capacity 24/7, while the serverless backend scaled with actual request volume.
Before — EC2
1
CloudFront + load balancer routing
+8ms total
2
App instance receives request
+3ms
3
Express request handler
+5ms
4
Query backend systems / database
+18ms
5
Build and return response
+4ms
Total: 38ms
After — Serverless
1
CloudFront → API Gateway routing
+20ms
2
Lambda handler starts
warm: +2ms
3
Load lightweight state / mapping
+2ms
4
Query backend systems
+5ms
5
Build and return response
+2ms
Total: ~31ms
Cold Start Trade-off: The occasional ~285ms Lambda cold start is an accepted trade-off for an internal API because (1) the cost savings far outweigh latency impact, (2) requests naturally warm the function, and (3) provisioned concurrency could mitigate if needed in future.
Monthly infra cost
100%
EC2 baseline
→
~70%
of EC2 cost
↓ ~30% cost reduction
Lambda scales with actual request volume instead of paying for idle EC2 capacity 24/7.
Daily API requests
100K/day
EC2 ASG · reserved
→
100K/day
Lambda · on-demand
Same volume · scales to zero at idle
Same request load. EC2 holds reserved capacity even at 0 req/s. Lambda provisions only when called — no idle billing.
Operational overhead
High
AMI · patching · ASG
→
Minimal
AWS-managed runtime
DevOps hours freed
No AMI builds, no OS patching, no ASG tuning. AWS manages the runtime. Zero instances to monitor.
Cold start latency
N/A
always warm
→
~300ms
occasional
Accepted tradeoff — internal API
Cold starts were an acceptable trade-off for an internal, non-latency-critical API.
Architecture decisions
Topic
Before — EC2
After — Serverless
Compute
EC2 ASG · t3.medium ×2
Lambda ARM64 Graviton2 · Node.js
Pay-per-invocation
Pay-per-invocation
Routing
Application Load Balancer · separate domain from SPA
CloudFront (existing) → API Gateway HTTP API
Single domain · no CORS · weighted origin cutover
Single domain · no CORS · weighted origin cutover
App state
In-memory per instance (not shared)
DynamoDB on-demand
Stateless Lambda · shared state
Stateless Lambda · shared state
Deployment
GitHub Actions → AMI bake → ASG refresh
GitHub Actions → package → Lambda · S3 sync + CF invalidation
No AMI · faster deploys · single pipeline
No AMI · faster deploys · single pipeline
Provisioned concurrency
N/A · always warm
Evaluated · not adopted
Cold starts acceptable — internal API · not latency-critical
Cold starts acceptable — internal API · not latency-critical
Patching / AMI
Regular AMI rebuilds · SSM patching
Eliminated — AWS-managed runtime
Ops overhead removed
Ops overhead removed
IaC
Terraform (ASG · ALB · IAM)
Terraform (API GW · Lambda · DynamoDB · IAM)
Fully automated · Git-tracked
Fully automated · Git-tracked
Platform Engineer
Me — infrastructure ownership
Full serverless infrastructure in Terraform
API Gateway HTTP API, Lambda function config (ARM64), DynamoDB table, all IAM roles and supporting resources. Existing CloudFront distribution and S3 bucket managed as IaC alongside the new backend.
GitHub Actions CI/CD pipeline
Terraform plan/apply stages, docker buildx for ARM64 cross-compilation, Lambda zip packaging and deploy, S3 sync for frontend assets, CloudFront cache invalidation — single pipeline for the full stack.
CloudFront routing & zero-downtime cutover
Reconfigured existing CloudFront distribution:
/api/* forwards to API Gateway, /* serves the SPA from S3 — single domain, no CORS changes needed. Used weighted origins to shift traffic from ALB to API Gateway over 48 hours with no downtime.Architecture decisions & tradeoff evaluation
Lambda vs Fargate, HTTP API vs REST API, DynamoDB vs Aurora Serverless. Evaluated provisioned concurrency — accepted cold starts as internal API with adequate traffic to keep functions warm.
Security & least-privilege IAM
Lambda execution roles scoped to exact DynamoDB actions per function. API Gateway resource policies, VPC Lambda integration for backend DB access.
Backend Developer
Collaborator — application ownership
Express → Lambda handler refactoring
Decomposed monolithic Express app into discrete per-route Lambda handlers — adapting middleware chains, connection management, and request/response lifecycle to Lambda's event-driven model.
DynamoDB data access layer
Implemented reads/writes for lightweight app state (user context, cached metadata, workflow mappings). Core aggregation data stayed in existing backend — no data migration required.
Frontend API client integration
Removed direct ALB endpoint references — frontend already called the CloudFront domain, so no domain changes were needed. Adapted API calls to the new route contract defined in infra (
/api/* paths).Observability & testing
CloudWatch Logs structured logging, X-Ray tracing for end-to-end request visibility. Unit tests for handler logic, integration tests against DynamoDB Local.
Where infra and app met — the integration layer
Platform engineering owned the infrastructure contract; the developer owned the application code.
Close collaboration was required at every boundary.
CloudFront behaviour paths (
/api/*) had to match exactly what the frontend called — defined together to avoid CORS edge cases and cache collisionsAPI Gateway route names and Lambda function identifiers had to align with the handler refactoring in progress
DynamoDB schema was co-designed: platform set capacity and access patterns, developer drove key structure based on query needs
Lambda IAM roles (written in Terraform) were scoped per-function — required understanding each handler's exact access to the existing backend
Zero-downtime cutover via CloudFront weighted origins — gradual traffic shift from ALB to API Gateway over 48 hours