AWS Lambda Optimization: Reducing Costs and Improving Performance
We run ~200 Lambda functions. Cold starts, memory tuning, and the cost-vs-latency trade-offs that actually move the bill.
Key takeaways
- We run ~200 Lambda functions.
- Cold starts, memory tuning, and the cost-vs-latency trade-offs that actually move the bill.
On this page
AWS Lambda Optimization: Cost and Performance
We run ~200 Lambda functions across various pipelines, event handlers, and APIs. Lambda is great when it fits and frustrating when it doesn't. After a couple of years of tuning, this is the working playbook for the optimizations that actually moved our metrics — and the ones that turned out to be folklore.
The two costs that matter#
A Lambda invocation costs:
- Memory-time: charged in GB-seconds. 512MB Lambda running 1 second = 0.5 GB-second.
- Number of requests: a flat ~$0.0000002 per invocation.
For most workloads, memory-time dominates. The request charge only matters at very high invocation rates (millions/day).
Memory tuning: the underrated lever#
Lambda gives you a single knob: memory. CPU and network bandwidth scale linearly with memory. So a 1024MB Lambda has roughly 2x the CPU of a 512MB one.
Two patterns:
For CPU-bound work, more memory is often cheaper. Sounds backwards. Example: a function that does PDF rendering. At 512MB it took 8s; at 2048MB it took 1.5s. The GB-seconds:
- 512MB × 8s = 4.0 GB-seconds
- 2048MB × 1.5s = 3.0 GB-seconds
The 2GB version is 25% cheaper despite more memory, because it finishes much faster. Plus, the user-facing latency is 5x better.
For I/O-bound work, less memory is often cheaper. A function that mostly waits for an external API: more memory doesn't speed it up. The 128MB version costs as much as 512MB version when the bottleneck is network round-trip time.
We use AWS Lambda Power Tuning (a step-function-based tool) to find optimal memory per function. It runs each function at multiple memory sizes and reports the cost-vs-latency curve. We re-run it quarterly per function.
Result: ~$1,200/month savings from memory tuning across our fleet.
Cold starts: real, manageable#
A cold start is when a new Lambda execution environment is created. It takes time:
- Init the runtime (Node, Python, Go, etc.)
- Load your code and dependencies
- Run any module-level initialization
For a Node.js function with 100MB of dependencies, cold start can be 2-5 seconds. For a small Go function, often < 200ms.
Things that help:
Smaller deployment package. Less code = less to load. We tree-shake and bundle (esbuild for Node, dataclass-only Python where possible). One function went from 80MB → 6MB → cold start dropped 1.8s.
Provisioned concurrency for latency-sensitive functions. Pre-warmed environments. Costs ~$8.50/month per provisioned instance, but cold starts go to ~10ms. We use this for ~5 functions where p99 latency matters.
SnapStart (Java) if you're on Java 11/17. Snapshotted JVM, drops cold starts from seconds to milliseconds. We have a few Java functions; SnapStart was a significant improvement.
Avoid heavy module-level work. Code that runs at the top level of the file runs once per cold start. Move expensive setup (DB connection pools, large config loads) to lazy initialization where possible.
What doesn't help much (from our testing):
- "Pinging your Lambda every 5 minutes" to keep it warm. Sometimes works for occasional traffic; doesn't work when the function actually has bursts (multiple environments needed simultaneously).
- Choosing a different runtime "because it's faster." Node and Python and Go all have similar cold-start profiles for similar workloads. Switch runtimes for application reasons, not Lambda reasons.
Architecture: when Lambda fits, when it doesn't#
Lambda is great for:
- Event-driven work (S3 trigger, SQS message, schedule)
- API endpoints with low or unpredictable traffic
- Glue work between AWS services
- Periodic jobs (scheduled, low frequency)
Lambda is bad for:
- High-throughput services (10k+ req/s sustained). The cost crosses over to ECS/EKS being cheaper, and the operational overhead of API Gateway in front of Lambda starts mattering.
- Long-running tasks (>15 minutes max).
- Anything needing low and stable latency. Cold starts mean p99 is always ugly.
- Stateful workloads.
We've moved a few functions back to ECS when they outgrew Lambda's economics. The crossover for our shape of services is around 100 req/s sustained. Below that, Lambda is cheaper. Above, ECS wins.
Cost overruns we've hit#
Specific cases that cost us money:
Lambda invoking Lambda invoking Lambda. Each layer of indirection multiplies cost. We had a chain where one event triggered three Lambdas in series, billed in full for each. Restructured to run the work in one Lambda. Saved $400/month.
API Gateway in front of low-traffic functions. API Gateway is ~$3.50 per million requests. For a Lambda doing 50 req/s, the API Gateway bill is bigger than the Lambda bill. We use Lambda Function URLs (cheaper, no API Gateway features) for some internal APIs.
Forgotten Lambdas left running. Old experiments, deprecated features. We have a quarterly review: any Lambda with < 100 invocations in 30 days gets its owner asked, "still needed?" About 20% get deleted each round.
SQS triggers without batching. Each SQS message triggers a Lambda invocation. For high-volume queues, batching messages (the BatchSize setting on the trigger) reduces invocation count by 10x.
CloudWatch Logs retention. Default retention is forever. Some Lambdas had years of logs at $0.50/GB-month. We set 30-day retention by policy. ~$200/month savings from that single change.
Concurrency limits#
Lambda has account-wide concurrency limits (default 1000 simultaneous executions). When you hit it, Lambdas get throttled — invocations fail with "TooManyRequestsException."
For burst-prone workloads, this matters. Mitigations:
- Reserved concurrency per function. Guarantees that function gets at least N concurrency, also caps it from consuming more. Useful for "this function shouldn't take down the whole account if it goes wild."
- Account limit increase. AWS will raise it via support ticket.
- SQS-based fan-out. SQS smooths the burst — Lambdas process the queue at the limit, not at the burst rate.
We ran into this once with a fanout that fired 5,000 Lambdas in one second. Half failed. We added reserved concurrency on the source function and SQS for the downstream.
Observability#
Lambda's built-in observability is minimal — CloudWatch Logs and a few metrics. For real visibility:
Structured logging. Every Lambda emits JSON logs with consistent fields (function, request_id, duration, status). We use the AWS Lambda Powertools libraries (Python, TypeScript, etc.) which do this well.
X-Ray tracing. Optional. We turn it on for functions in critical paths. Adds visibility into what's slow within an invocation. Cost is real — X-Ray traces aren't free — so we don't enable it by default.
Metric filters on logs. Custom metrics derived from log patterns. E.g., "count of error logs containing 'database timeout'" becomes a metric we can alert on.
Lambda Insights (an AWS feature). Enhanced metrics including CPU steal, memory utilization, network. We enable it on Lambdas where we suspect resource contention.
Specific patterns that work#
Init outside the handler. Heavy setup (DB clients, SDK clients) goes outside the handler so it's reused across invocations within the same execution environment.
# Top of file - runs once per cold start
db_client = boto3.client("dynamodb")
def handler(event, context):
# Reuses db_client across warm invocations
return db_client.get_item(...)
Connection pooling carefully. Each Lambda execution environment is independent. A connection pool inside the Lambda doesn't share across instances; if you have 100 concurrent Lambdas each with 10 connections, that's 1000 connections to your database. RDS Proxy helps mitigate this for relational databases.
Idempotency keys. Lambda might invoke your handler twice for the same event (rarely, but it happens — SQS at-least-once delivery, retries on failure). We pass an idempotency key (typically the event ID) and use it to dedupe at the destination (DynamoDB conditional write, etc.).
Graceful failure handling. Failed Lambda invocations go to dead-letter queues (or destinations, the newer feature). DLQs collect the failures so we can investigate without losing data.
What we no longer recommend#
- Layers for shared dependencies. They seemed great early on; they make deploys complicated and limit you to specific runtimes. We bundle dependencies into each function instead.
- Custom runtimes for "performance". The pre-built Node/Python/Go runtimes are optimized. Custom runtimes (the runtime API) are for specific cases (Rust, Zig, etc.). Most teams shouldn't bother.
- Single Lambda for many event types with branching logic. Easier to deploy initially, harder to monitor and tune. We split into one Lambda per event type when functions get >50 lines of routing logic.
What I'd tell someone starting#
Run Power Tuning on every function. The memory sweet spot isn't intuitive. Tune empirically.
Watch for "Lambda invoking Lambda" patterns. They multiply cost and latency. Restructure to do the work in fewer invocations.
Set CloudWatch Logs retention by policy. 30 days for most, 7 days for noisy ones. Don't pay to store logs you'll never read.
Track cost per function, not just total Lambda cost. Tag every function with team and feature; the AWS bill becomes much more actionable.
Lambda's not always the answer. When traffic outgrows Lambda economics or latency requirements, move to ECS/EKS. The crossover is real.
Lambda done well is one of AWS's best products. Lambda done badly is a cost surprise waiting to happen. The difference is whether you spent a couple of hours tuning each function or just deployed defaults. The tuning pays for itself quickly.
Stay Updated
Get the latest tutorials, guides, and insights on AI, DevOps, Cloud, and Infrastructure delivered directly to your inbox.
DevOps Metrics and KPIs: Measuring Success
We track the four DORA metrics plus a handful of others. The trade-off between what's measurable and what's meaningful, and how we use the numbers.
Multi-Region Deployment: Building Resilient Cloud Applications
We run our app in two AWS regions for failover. The hard parts aren't the deployment — they're data consistency, traffic shifting, and the assumptions that break when "primary" is suddenly the wrong region.
More from Cloud
Explore more articles in this category
Best Serverless Databases in 2026 (Compared)
A practitioner comparison of the leading serverless databases by use case, cold-start behavior, branching, pricing model, and lock-in.
Cloudflare D1: The Edge SQLite Database Guide (2026)
A practitioner's look at Cloudflare D1, the serverless SQLite database built for Workers, covering setup, read replication, limits, and fit.
Neon vs PlanetScale: Serverless SQL Compared (2026)
A practitioner comparison of Neon's serverless Postgres against PlanetScale's Vitess-backed MySQL to help you pick the right database.
You might have missed
Evergreen posts worth revisiting.