Serverless bills are easy to ignore until they are not. The invoice arrives as a hundred small line items -- Lambda GB-seconds, API Gateway requests, CloudWatch Logs ingestion, NAT Gateway data processing, DynamoDB reads -- and no single one looks big enough to investigate. This post is the cost review we run on Serverless Framework projects: what AWS actually bills you for, where the money usually goes, and the serverless.yml changes that cut the invoice without changing behaviour.
Everything here assumes Serverless Framework V4 on AWS. Prices quoted are us-east-1 list prices and are there to show the shape of the arithmetic -- check the current pricing pages before you build a business case.
What Lambda actually bills
Three components:
- Requests -- about $0.20 per million invocations.
- Duration -- GB-seconds: configured memory multiplied by billed time, rounded to the millisecond. Roughly $0.0000166667 per GB-second on x86 and about 20% less on arm64 (Graviton2).
- Storage above the free ephemeral 512 MB, plus Provisioned Concurrency and SnapStart charges if you use them.
Two changes are worth knowing about because they invalidate older advice:
- The INIT phase is billed. Since August 2025, initialization time for on-demand invocations on managed runtimes is included in billed duration. It used to be free. If you have a function with a 1.5-second init and a 50 ms handler, your bill for that function just became mostly cold start. This makes the bundle-trimming work in our cold start post a cost lever, not only a latency lever.
- Duration pricing is tiered. Above roughly 6 billion GB-seconds per month the rate drops, and again above 15 billion. Almost nobody reaches this, but if you are consolidating accounts it is worth knowing the tiers apply per payer account.
The practical consequence: your Lambda cost is invocations x memory x duration. There are exactly three dials, and two of them are under your control in serverless.yml.
Step 1: Find out where the money is before touching anything
Do not optimize from intuition. Tag everything, then read the bill.
provider:
name: aws
stackTags: # applied to every resource in the stack
project: checkout
stage: ${sls:stage}
owner: platform-team
tags: # applied to the Lambda functions
project: checkout
stage: ${sls:stage}
Activate project and stage as cost allocation tags in the Billing console (they take up to 24 hours to appear), then group by them in Cost Explorer. Filter to a single service and group by USAGE_TYPE to see whether Lambda spend is duration, requests, or provisioned concurrency.
For per-function attribution, CloudWatch metrics get you most of the way. This Logs Insights query ranks functions by GB-seconds over the last week:
filter @type = "REPORT"
| stats sum(@billedDuration * @memorySize / 1024 / 1000) as gbSeconds,
count(*) as invocations,
avg(@billedDuration) as avgMs
by @log
| sort gbSeconds desc
| limit 20
In most reviews the top three functions are 80% of the compute bill, and one of them is doing something nobody remembered was still running.
Step 2: Switch to arm64
The single highest-return line change in a serverless project:
provider:
architecture: arm64
Graviton pricing is roughly 20% lower per GB-second, and CPU-bound workloads frequently run faster as well, so the real saving is often larger. The caveats are narrow: native binaries (sharp, bcrypt, anything with a .node addon) must be built for ARM, and a handful of Lambda layers are x86-only. Build in CI on an ARM runner or with a container image, deploy to a staging stage, and compare Duration before promoting.
Step 3: Right-size memory in both directions
Memory is the multiplier on every billed millisecond, but less memory is not automatically cheaper -- Lambda scales CPU with memory, so an under-provisioned function pays for its own slowness. Run AWS Lambda Power Tuning with the cost optimization strategy and set the result per function rather than a blanket provider default:
provider:
memorySize: 512 # sane default
functions:
thumbnail:
handler: src/thumbnail.handler
memorySize: 2048 # CPU-bound: faster AND cheaper here
webhookAck:
handler: src/webhook.handler
memorySize: 256 # I/O-bound, does nothing but write to SQS
Two related settings people forget:
functions:
reportBuilder:
timeout: 30 # not the 900s someone set while debugging
ephemeralStorageSize: 512
A long timeout costs nothing by itself, but it is what turns a hung HTTP call into 15 minutes of billed duration multiplied by every retry.
Step 4: Stop paying for logs you never read
On many small projects CloudWatch Logs costs more than Lambda. Ingestion is around $0.50 per GB, storage around $0.03 per GB-month, and the default retention in a Serverless Framework project is never expire.
Set retention on every log group:
provider:
logRetentionInDays: 14 # V4 supports this at provider level
Then reduce what you write:
- Use a structured logger with a level controlled by an environment variable, and run production at
WARNorINFO-- notDEBUG. Powertools for AWS Lambda readsPOWERTOOLS_LOG_LEVELand supports percentage-based debug sampling so you keep a few verbose traces without paying for all of them. - Turn off API Gateway execution logging and full request/response logging in production; access logs in a compact JSON format are enough.
- Never
console.logan entire event or SDK response in a hot path. One 8 KB event logged on 50 million invocations a month is roughly 400 GB of ingestion. - For logs you must retain but rarely query, Lambda's Infrequent Access log class halves ingestion cost, at the price of losing Live Tail and some Insights features.
functions:
chatty:
handler: src/chatty.handler
logs:
logFormat: JSON
applicationLogLevel: WARN # runtime-level filtering, before ingestion
systemLogLevel: WARN
Advanced logging controls filter at the runtime, so suppressed lines are never ingested and never billed. That is strictly better than filtering in your logger alone.
Step 5: Pick the cheaper front door
For a plain JSON API, HTTP API costs about $1.00 per million requests; REST API costs about $3.50 per million plus optional caching. Unless you need per-request WAF integration, API keys with usage plans, request validation models, or private endpoints, use httpApi:
functions:
api:
handler: src/api.handler
events:
- httpApi: # not "http" (that is REST API)
path: /orders
method: post
If the endpoint is internal, machine-to-machine, or a webhook receiver, a Lambda Function URL costs nothing at all beyond the Lambda invocation. It gives you no throttling and no request validation, so put IAM auth or a shared-secret check in front of it.
Step 6: Check for the invisible line items
These are the ones that surprise people:
- NAT Gateway. About $0.045 per hour per gateway plus $0.045 per GB processed. If your functions are in a VPC only so they can reach DynamoDB, S3, or Secrets Manager, replace the NAT Gateway with VPC endpoints (Gateway endpoints for S3 and DynamoDB are free) -- or take the functions out of the VPC entirely. Three AZs of NAT Gateway that exist for one RDS query is roughly $100 a month before any traffic.
- DynamoDB capacity mode. On-demand is the right default for spiky and unknown workloads. For a predictable baseline, provisioned capacity with autoscaling can be several times cheaper. Check the
ConsumedReadCapacityUnitsgraph for a week before deciding. - Step Functions. Standard workflows bill per state transition (~$25 per million); Express workflows bill on duration and are dramatically cheaper for high-volume, short-lived orchestration. Swapping a hot Standard workflow to Express is often a 90% cut on that line.
- Idle non-production stages. Provisioned Concurrency, warm RDS instances, and OpenSearch domains in
devandstagingrun 24/7 by default. Schedule them down or make review stages ephemeral and delete them withserverless removewhen the pull request closes. - X-Ray at 100% sampling. Traces cost per recorded trace; sample at 5-10% in production and keep 100% only for error paths.
Step 7: Put a guardrail on it so it does not drift back
Cost work that is not automated regresses within a quarter. Two cheap defences:
A budget with an alert, defined in the same stack:
resources:
Resources:
MonthlyBudget:
Type: AWS::Budgets::Budget
Properties:
Budget:
BudgetName: ${self:service}-${sls:stage}
BudgetLimit:
Amount: 200
Unit: USD
TimeUnit: MONTHLY
BudgetType: COST
CostFilters:
TagKeyValue:
- 'user:project$checkout'
NotificationsWithSubscribers:
- Notification:
NotificationType: FORECASTED
ComparisonOperator: GREATER_THAN
Threshold: 80
ThresholdType: PERCENTAGE
Subscribers:
- SubscriptionType: EMAIL
Address: platform-team@example.com
Concurrency caps so a runaway loop or a retry storm cannot bill you a fortune before anyone wakes up:
functions:
webhookProcessor:
handler: src/webhook.handler
reservedConcurrency: 20 # hard ceiling on parallel executions
Reserved concurrency is a blast-radius control as much as a cost control: it caps spend and protects downstream databases from being hammered by a scaling event.
A realistic outcome
A recent review of a mid-sized Serverless Framework estate: arm64 across the board (-18% on compute), memory right-sizing on the six busiest functions (-22% more, with lower p95 latency), log retention plus applicationLogLevel: WARN (CloudWatch spend down 70%, the largest single saving), REST API to HTTP API on two services, and a NAT Gateway removed in favour of VPC endpoints. Total: a little under 45% off the monthly bill, no functional change, about three days of work.
The order matters. Measure, then tag, then change one dial at a time and compare a full billing day before moving on.
If you would like this run against your own account, our Serverless Performance Tuning and DevOps for Serverless Apps engagements include a cost review with the numbers written down. Get in touch and tell us roughly what you are spending today.