AWS Lambda Cost Optimization: Memory, arm64 and Retries

AWS Lambda cost optimization with us-east-1 prices: memory tuning, arm64, timeouts and retries, batching, provisioned concurrency and when to move off.

A Lambda function with 1 GB of memory costs six cents for every hour it spends running. For most functions that’s a rounding error, which is why Lambda rarely gets a cost review. The bill climbs when something multiplies those hours: a fifteen-minute timeout on a call that hangs, three attempts at every failure, a consumer that wakes once per message, or warm capacity paid for all night so a dashboard loads fast at 9am.

Each of those multipliers is a setting, not an architecture decision. Here’s how Lambda is metered, which settings to change, and the point at which a function is busy enough that it should stop being one.

What Lambda actually bills for

Lambda charges for requests and duration. Duration is in GB-seconds: configured memory times run time, rounded up to the millisecond. Since August 1, 2025, that includes the INIT phase for every function type, so a slow cold start bills like any other compute.

These are the us-east-1 on-demand list prices at the time of writing:

Charge x86_64 arm64
Requests $0.20 per million $0.20 per million
Duration, first 6 billion GB-s a month (arm64: 7.5 billion) $0.0000166667 per GB-s $0.0000133334 per GB-s
Duration, next 9 billion (arm64: next 11.25 billion) $0.0000150000 per GB-s $0.0000120001 per GB-s
Duration, beyond that $0.0000133334 per GB-s $0.0000106667 per GB-s
Provisioned concurrency, while enabled $0.0000041667 per GB-s $0.0000033334 per GB-s
Duration on provisioned concurrency $0.0000097222 per GB-s $0.0000077778 per GB-s
Ephemeral storage above the free 512 MB $0.0000000309 per GB-s $0.0000000309 per GB-s

The free tier covers 1 million requests and 400,000 GB-seconds a month, but not provisioned concurrency. Duration tiers are counted per Region and architecture (across an organization with consolidated billing), and the second one starts after about $100,000 of x86 duration in a month, so most teams never reach it. Extra ephemeral storage costs roughly 1/540th of the same amount of memory, so it’s rarely worth chasing.

Before changing anything, find out which line is large. This pulls last month’s Lambda spend by usage type:

# Read-only. Cost Explorer charges $0.01 per API request.
START=$(date -u -d "$(date -u +%Y-%m-01) -1 month" +%Y-%m-%d 2>/dev/null || date -u -v1d -v-1m +%Y-%m-%d)
END=$(date -u +%Y-%m-01)
aws ce get-cost-and-usage \
  --time-period Start="$START",End="$END" \
  --granularity MONTHLY \
  --metrics UnblendedCost \
  --filter '{"Dimensions":{"Key":"SERVICE","Values":["AWS Lambda"]}}' \
  --group-by Type=DIMENSION,Key=USAGE_TYPE \
  --query 'ResultsByTime[0].Groups[].[Keys[0],Metrics.UnblendedCost.Amount]' \
  --output text | sort -t$'\t' -k2 -nr

In us-east-1 the usage types read Lambda-GB-Second, Lambda-GB-Second-ARM, Request and Lambda-Provisioned-Concurrency; other Regions add a prefix such as USE2-. If GB-seconds dominate, look at memory, timeouts and batching first.

Tune memory with measurements

Memory is Lambda’s only size knob, and it also sets CPU. You can pick 128 MB to 10,240 MB in 1 MB steps, and CPU scales in proportion: at 1,769 MB a function gets one full vCPU. So “lower memory is cheaper” is often wrong. A CPU-bound function given more memory finishes sooner, and if it finishes enough sooner, the GB-seconds go down.

Say a function runs for 800 ms at 512 MB: 0.4 GB-s per call, about $6.67 per million invocations at x86 prices. At 1,024 MB it might finish in 380 ms, which is $6.33 per million and faster. At 2,048 MB it might only reach 300 ms, which is $10.00 per million. The cheapest point sits somewhere in the middle, and guessing won’t find it.

AWS Lambda Power Tuning will. It’s an open-source Step Functions state machine you deploy in your own account. Give it a function ARN, memory values and a payload, and it invokes the function at each setting and reports cost and duration:

{
  "lambdaARN": "arn:aws:lambda:us-east-1:123456789012:function:resize-images",
  "powerValues": [256, 512, 1024, 1769, 3008],
  "num": 50,
  "payload": {"bucket": "test-uploads", "key": "sample.jpg"},
  "strategy": "cost"
}

Two cautions. The invocations are real, so a function that charges a card or sends email will do it num times per setting; use a test alias and a harmless payload. And the payload must be representative: tuning an image resizer on a 4 KB thumbnail tells you nothing about its 12 MB uploads.

Functions that mostly wait on a third-party API gain nothing from more CPU. For those, the saving is the lowest memory that doesn’t run out, which the REPORT lines show in Logs Insights (billed per GB scanned, so keep the range short):

filter @type = "REPORT"
| stats count(*) as invocations,
        avg(@billedDuration) as avg_billed_ms,
        max(@maxMemoryUsed / 1000000) as max_used_mb,
        max(@memorySize / 1000000) as configured_mb,
        sum(@billedDuration / 1000 * @memorySize / 1000000 / 1024) as gb_seconds

Compute Optimizer also recommends Lambda memory once you opt in, but only for x86_64 functions.

Move to arm64 where the code allows

On arm64 a GB-second costs 20% less, with the same request price. For Python or Node.js without native extensions, switching is often a one-line configuration change. What breaks is compiled dependencies, x86-only layers and extensions without an arm64 build. We cover a safe rollout, including weighted aliases, in our rightsizing and Graviton guide.

This lists the functions still on x86 in a Region:

# Read-only. Repeat per Region.
aws lambda list-functions \
  --query "Functions[?Architectures[0]=='x86_64'].[FunctionName,Runtime,MemorySize]" \
  --output table

Re-run Power Tuning after switching, because the cheapest memory setting may move.

Timeouts and retries multiply the bill

A function waiting on a downstream service is billed for every millisecond of the wait. With the timeout at the 900-second maximum, one hung call at 1,024 MB costs 900 GB-s, about $0.015. Small, until retries join in.

For asynchronous invocations (S3 notifications, SNS, EventBridge), Lambda retries a failed event twice by default, and keeps retrying throttled ones for up to six hours. One event that times out therefore bills three full timeouts. If a downstream outage makes 10,000 events hang, that’s roughly $450 of duration doing nothing. With a 10-second timeout, about $5.

Set timeouts to what the function needs plus headroom, then find the ones nobody tightened:

# Read-only. Functions with a timeout of 5 minutes or more.
aws lambda list-functions \
  --query 'Functions[?Timeout>=`300`].[FunctionName,Timeout,MemorySize]' \
  --output table

Where a failed event is better off in a dead-letter queue than retried, lower the retries and event age. Agree it with the function’s owner first:

# Changes configuration. Send failures to an on-failure destination before lowering retries.
aws lambda put-function-event-invoke-config \
  --function-name orders-webhook \
  --maximum-retry-attempts 1 \
  --maximum-event-age-in-seconds 3600

Event sources have their own version. On SQS, one bad message returns the whole batch unless you enable partial batch responses (ReportBatchItemFailures). On Kinesis and DynamoDB streams, a failed batch is retried until it succeeds or the records expire, with the shard stalled behind it. Set a maximum retry count, turn on batch bisection and add an on-failure destination.

Batch your event sources

Every invocation carries fixed overhead: the request charge, per-call setup in your handler, sometimes a cold start. A consumer reading one message at a time pays it per message.

SQS standard queues allow batches of up to 10,000 records, and anything above 10 needs a batching window of at least one second (up to 300). Streams take a window too. Lambda invokes when the batch fills, the window expires or the payload reaches 6 MB.

The request charge isn’t where the saving is: batching 10 million messages a month in tens instead of ones cuts it from $2.00 to $0.20. The saving is the per-call work that disappears, such as connection setup and token fetches. If your handler does that on every call, batching can shrink duration a lot.

The trade-offs: a window adds latency, so keep it off queues behind user-facing actions, and bigger batches mean more rework when one record fails, which is what partial batch responses fix. Raise the queue’s visibility timeout too; AWS recommends at least six times the function timeout plus the window.

Event filtering on the mapping is the other half. Records that don’t match never invoke the function, so a consumer that discards most of its input should filter before Lambda wakes up, not inside the handler.

Provisioned concurrency only where cold starts hurt

Provisioned concurrency keeps environments initialised and bills for them used or not: ten at 1 GB on x86 cost about $109.50 a month before a single request. In exchange, their duration is billed at $0.0000097222 per GB-s instead of $0.0000166667.

The arithmetic gives a clean break-even: provisioned concurrency beats on-demand only when the environments are busy more than about 60% of the time, on either architecture. Below that you’re paying for latency. That’s fine for a synchronous API where a two-second cold start loses a customer, and wasted on a nightly batch job or anything behind a queue.

If traffic follows office hours, schedule it with Application Auto Scaling rather than holding it all night. Then check how much gets used:

# Read-only. Provisioned concurrency configured on each function in this Region.
for fn in $(aws lambda list-functions --query 'Functions[].FunctionName' --output text); do
  aws lambda list-provisioned-concurrency-configs --function-name "$fn" \
    --query 'ProvisionedConcurrencyConfigs[].[FunctionArn,AllocatedProvisionedConcurrentExecutions]' \
    --output text
done

# Read-only. Peak hourly utilisation over the last 14 days for one alias.
START=$(date -u -d '14 days ago' +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -v-14d +%Y-%m-%dT%H:%M:%SZ)
END=$(date -u +%Y-%m-%dT%H:%M:%SZ)
aws cloudwatch get-metric-statistics \
  --namespace AWS/Lambda --metric-name ProvisionedConcurrencyUtilization \
  --dimensions Name=FunctionName,Value=checkout-api Name=Resource,Value=checkout-api:live \
  --start-time "$START" --end-time "$END" --period 3600 --statistics Maximum \
  --query 'max(Datapoints[].Maximum)'

If the peak never gets near 1.0, the allocation is too big. If it barely moves off zero, consider removing it:

# DESTRUCTIVE: cold starts return for this alias. Confirm with the owner first.
read -r -p "Remove provisioned concurrency from checkout-api:live? [y/N] " ok
[ "$ok" = "y" ] && aws lambda delete-provisioned-concurrency-config \
  --function-name checkout-api --qualifier live

SnapStart instead, where it fits

For Java 11+, Python 3.12+ and .NET 8+, SnapStart restores an initialised snapshot instead of running INIT again. On Java it costs nothing extra. On Python and .NET you pay to cache each published version’s snapshot ($0.0000015046 per GB-s, three-hour minimum) and per restore ($0.0001397998 per GB), so a pipeline that publishes a version on every commit keeps paying for snapshots nobody invokes. SnapStart doesn’t work with provisioned concurrency, EFS or ephemeral storage above 512 MB.

Stop recursive loops before the invoice does

The most expensive Lambda bug is a function that triggers itself, say by writing to the bucket that invokes it. Recursive loop detection catches loops through SQS, SNS, S3, EventBridge custom buses and between functions, stopping the chain after about 16 invocations. It’s on by default and free, but blind to other services, DynamoDB streams included.

# Read-only. "Terminate" is the safe default; "Allow" disables the protection.
aws lambda get-function-recursion-config --function-name thumbnailer

If a function returns Allow, find out why. For loops detection can’t see, reserved concurrency is free and caps how many copies run at once.

When a busy function should be a container

Lambda’s pricing suits spiky work that’s idle most of the time. An environment busy around the clock pays a premium for scale-to-zero it never uses.

Take a function at 1,769 MB (one vCPU) busy every hour of a 730-hour month. Duration alone is about $75.67 on x86 and $60.53 on arm64. A Fargate task with 1 vCPU and 2 GB costs about $36.04 on x86 and $28.84 on ARM in us-east-1. For I/O-bound work the gap is wider, because a Lambda environment serves one request at a time and a container can serve many.

Containers bring their own costs (load balancer, scaling, patching, peak headroom), so move a few steady, high-volume functions, not the long tail. Lambda Managed Instances sit in between: the Lambda programming model on EC2 instances you choose, several invocations per environment, and the EC2 price plus a 15% management fee. Savings Plans and Reserved Instances discount the instance part but not the fee, and capacity doesn’t scale to zero.

Compute Savings Plans cover Lambda too; our Reserved Instances vs Savings Plans guide covers sizing one. And every invocation writes log lines that CloudWatch charges to ingest, which on a chatty function can rival its compute cost and is the subject of our CloudWatch Logs to S3 article.

FAQ

How is AWS Lambda cost calculated?

Requests at $0.20 per million, plus configured memory in GB times billed seconds at $0.0000166667 per GB-s (x86) or $0.0000133334 (arm64) in us-east-1. Provisioned concurrency, SnapStart on Python or .NET and extra ephemeral storage come on top.

Does increasing Lambda memory increase cost?

Not always. More memory adds CPU, so CPU-bound functions often finish fast enough to cost less. Measure with Power Tuning.

Is arm64 Lambda cheaper than x86?

Yes, 20% less per GB-second at the same request price. Check native dependencies first and re-tune memory afterwards.

Is Lambda provisioned concurrency worth the cost?

Only for latency-sensitive functions that keep it busy. It beats on-demand above about 60% utilisation; below that you’re buying latency, not savings.

When is Lambda more expensive than Fargate or EC2?

When a function runs almost continuously. A one-vCPU function busy all month costs about twice as much on Lambda as a comparable Fargate task.

Fix your top function first

Run the Logs Insights query over the last week across your ten busiest log groups and sort by gb_seconds. Take the top function, run Power Tuning against a test alias with a realistic payload and ship whichever memory setting wins on cost, then check its timeout and architecture. Those three settings on one function usually show whether the rest of the account deserves the same treatment, and they’re easy to add to a regular automated scan afterwards.