Skip to content
← Writing
Technical guideFinOps11 min

Run a real API for $0 a month on Lambda's always-free tier

A bookmarks API on a Lambda function URL and DynamoDB: which free limit runs out first, what a traffic flood can cost, and settings that break $0.

By Rahul Ladumor
On this page

The AWS Lambda free tier limits are 1 million requests and 400,000 GB-seconds of compute a month, and they don't expire after 12 months the way some offers do. That's enough to run a real backend for $0, if you know which limit you'll hit first and what happens when traffic goes wrong. We built a small bookmarks API to find out, and turned the answers into a tested repo.

This article is for developers who want a free backend on AWS for a side project or a personal tool. You get the free-tier math, a front door that costs nothing, and a hard ceiling on the worst month. There are also alarms that cost nothing, plus an AWS Cloud Development Kit (CDK) stack.

Key takeaways

  • For a fast API, the 1 million free requests run out long before the 400,000 GB-seconds. Extra memory costs nothing until requests get slow.
  • A Lambda function URL costs nothing extra. With auth type NONE, your free requests belong to anyone who finds the URL.
  • Reserved concurrency plus the function URL's request limit puts a hard cap on the bill. With 2 reserved copies at 256 MB, the worst possible month is $23.04.
  • A CloudWatch alarm that uses metric math doesn't count toward the 10 free alarm metrics. Give each alarm one plain metric.
At a glance
Level Beginner
Time to read 11 minutes
Cost $0 within the always-free tier. Worst case with the default cap: $23.04 for a month
Code InfraTales/lambda-always-free-api
Tested with Node.js 24, AWS CDK 2, DynamoDB Local, 84 automated tests, cdk-nag

Lambda free tier limits: the math that matters

Lambda bills two things. Each request costs $0.20 per million after the first million. Lambda bills compute in GB-seconds: how long each request runs, rounded up to the next millisecond, times the memory you gave the function. The free tier covers 1 million requests and 400,000 GB-seconds every month, on the Free and Paid plans alike.

Arm and x86 functions draw from one shared allowance, not one each. AWS's Graviton launch post says Arm functions are "included in the AWS Free Tier up to the existing limits". A 2024 post on the AWS Architecture Blog says the 400,000 GB-seconds are "usable for functions across both x86 and AWS Graviton processors". Past the free tier, Arm compute costs $0.0000133334 per GB-second against $0.0000166667 for x86, so we use Arm.

Compute and requests run out at the same time only when requests are slow. You can work out that point for each memory size:

Memory Both limits run out together at a mean billed duration of
128 MB 3,200 ms
256 MB 1,600 ms
512 MB 800 ms
1,024 MB 400 ms

An API that answers in 20 ms is nowhere near any of these. Your limit is the request count, and extra memory costs you nothing inside the free tier, so we gave the function 256 MB for headroom.

Two more billing details matter. Rounding happens per request, so average the billed durations instead of rounding an average. And since August 1, 2025, Lambda bills the initialization phase of a cold start too.

What we measured on a laptop

The repo has a measure script that runs the bundled handler the way one Lambda environment does: load it once, then serve one request at a time. We ran 1,000 rounds of create, get, list and update, plus a delete in every second round, against DynamoDB Local on an Apple silicon laptop with Node.js 24.

The mean billed duration came out between 1.3 and 4.5 ms across four runs. The laptop was busy with other work, which explains the spread. Real Lambda adds a network trip to DynamoDB, so expect more, but still far below 1,600 ms. A successful request wrote 0 bytes of logs, and an average response was about 1.4 KB.

The free-tier-model script turns those numbers into a monthly picture:

Lambda, per month
  free by request count 1,000,000
  free by compute       352,422,907  (400,000 GB-s / GB-s per request)
  => 1,000,000 requests a month cost $0. The 1M request allowance runs out first.

Notice the gap. Compute alone would cover over 350 million requests a month, 350 times the request allowance. After you deploy, pass your real billed duration to the script with --duration-ms.

Choose a front door that costs nothing

Amazon API Gateway's free tier lasts 12 months and is for new customers only. Free plan accounts get none of it, since the Free plan comes with only the always-free offers. Without the free tier, an HTTP API costs $1.00 per million requests. A Lambda function URL is an HTTPS endpoint on the function itself, and the Lambda docs say there are "no additional charges for the URL endpoint itself".

The whole design fits in one picture:

Signed client calls a Lambda function URL, capped by reserved concurrency, which writes to DynamoDB

The client signs each request. The function URL checks the signature, and the function reads and writes one DynamoDB table. CloudWatch watches the function's metrics and emails you through SNS when usage gets close to the free limits.

A function URL has two auth types. With NONE, Lambda runs your function for anyone on the internet, and each of those calls spends your free requests. With AWS_IAM, Lambda checks a Signature Version 4 (SigV4) signature before your code runs, and unsigned requests get 403.

We use AWS_IAM, and the handler uses the caller's identity to keep each caller's data apart:

// Stack: Lambda checks the signature before the function runs.
fn.addFunctionUrl({ authType: lambda.FunctionUrlAuthType.AWS_IAM, invokeMode: lambda.InvokeMode.BUFFERED });

// Handler: Lambda passes the signed caller's IAM ARN.
const owner = event.requestContext?.authorizer?.iam?.userArn;
if (!owner) return problem(403, 'this API needs AWS_IAM auth on the function URL');

The owner check matters if someone flips the URL to NONE later. Lambda then sends no identity at all, so the handler refuses the request rather than guess whose bookmarks to show. One more detail: since October 2025, new function URLs need both lambda:InvokeFunctionUrl and lambda:InvokeFunction permissions. CDK's grantInvokeUrl adds both, and a template test checks it.

Put a hard ceiling on the bill

The free tier is a monthly allowance, not a limit. Nothing stops a buggy client from sending 50 million requests. Reserved concurrency is the setting that bounds how much the function can run. It sets the most copies of the function that may run at once, and it costs nothing to configure.

Function URLs add a second limit. The docs say the maximum request rate "is equivalent to 10 times the configured reserved concurrency". Callers above either limit get HTTP 429. The Lambda metrics docs say throttled requests aren't recorded as invocations, and that invocations equal billed requests.

Those two limits give you a worst case you can compute. Below 100 ms per request, the rate limit caps the request count. Above 100 ms, concurrency caps compute. So the worst month is the one where every request takes 100 ms:

Reserved concurrency Memory Most requests in 31 days Most GB-seconds Worst Lambda bill (Arm)
1 128 MB 26,784,000 334,800 $5.16
2 256 MB 53,568,000 1,339,200 $23.04
5 256 MB 133,920,000 3,348,000 $65.89

The repo uses 2 reserved copies, so a client can send at most 20 requests a second. With 1 copy at 128 MB, compute can't leave the free tier at all, and only requests can cost money. The repo's smoke test fires 60 parallel calls after a deploy and expects some of them to get 429.

One trap catches new accounts. Lambda keeps 100 units of account concurrency unreserved, and new accounts start with a lower concurrency quota. If your quota is too small to reserve 2 more units, the deploy fails. Check the quota in Service Quotas before you deploy, and ask for an increase rather than removing the cap.

Alarm before the free tier runs out

A monthly total is hard to alarm on, because a CloudWatch alarm can look back at most 7 days. Instead, we alarm on pace. If one day uses more than 1/31 of the monthly allowance, a 31-day month at that rate would run out. The alarm fires at 80% of that daily share: 25,806 invocations, or 10,322 GB-seconds.

The stack sets up the request alarm like this. This is a simplified excerpt; the real code builds the threshold and description from the stack's props:

new cloudwatch.Alarm(this, 'RequestsPace', {
  metric: fn.metricInvocations({ statistic: 'Sum', period: cdk.Duration.days(1) }),
  threshold: Math.floor((1_000_000 * 0.8) / 31), // 25,806
  comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD,
  evaluationPeriods: 1,
  treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
});

A third alarm fires when Lambda throttles anything, so you know callers are getting 429. All three notify an Amazon Simple Notification Service (SNS) topic, which gives 1,000 email notifications free each month. If other functions share your account, set monthlyRequestBudget to this API's share, since they all draw from the same 1 million.

What we got wrong first: a paid alarm

Our first compute alarm used CloudWatch metric math. It took the daily Duration sum and multiplied it into GB-seconds, which made a tidy graph in the console. Our tests didn't check how the alarm was built, so nothing flagged it.

Then the fact check of the CloudWatch pricing page turned up the condition on the free alarms. The 10 free alarm metrics are "only applicable to Standard resolution alarms that list metrics directly". A metric math alarm lists an expression instead, so our free-tier alarm would have cost $0.10 a month. The fix moves the math into the threshold. The alarm watches the raw Duration sum, and the threshold becomes 41,288,000 ms a day at 256 MB.

A test now checks that every alarm names one metric and no math expression. We broke it on purpose by putting the metric math back, and the test failed. The same habit caught a bug in our model too: its first version rounded an average of billed durations up a second time, which overstated compute by 57%.

Keep storage at $0 too

DynamoDB's always-free tier gives 25 read and 25 write capacity units and 25 GB of storage per Region. It applies to provisioned tables, not on-demand ones. We provision 5 of each with no auto scaling, which leaves room for other tables in the account. Article 3 in this series goes deeper on DynamoDB design.

Every write is conditional. A create uses attribute_not_exists, so it can't overwrite. An update needs the version from the If-Match header, so two clients editing the same version can't both win, and a deleted bookmark can't come back. The integration tests run these conditions on DynamoDB Local, including 20 updates racing for one version.

What breaks the $0

Some settings add a charge without warning you. The ones that matter for this design:

Setting What it costs
Logging every request Lambda logs count as vended logs, at $0.50 per GB, and we couldn't confirm the 5 GB of free logs covers them
Provisioned concurrency The pricing page says the free tier doesn't apply to functions that use it
On-demand DynamoDB No always-free capacity
An eleventh alarm metric $0.10 a month each
Point-in-time recovery on the table $0.20 per GB-month from the first GB

Because of the logging line, the stack sets the system log level to WARN. That drops the START and REPORT lines of successful requests, so a good request writes nothing. To see your real billed duration and memory, deploy for a day with -c reportLines=true and query those lines with CloudWatch Logs Insights.

What it costs

These are us-east-1 prices from the AWS Price List API. They assume this API has the free tier to itself:

Monthly traffic Lambda DynamoDB, alarms, SNS Total
1 million requests $0 $0 $0
2 million requests $0.20 $0 $0.20
Worst case, callers keep both copies busy for 31 days $23.04 $0 $23.04

Responses leave AWS over the internet, and AWS gives 100 GB of data transfer out free each month across services. At about 1.4 KB per response, a million requests send 1.4 GB, so even if all of it counts you stay far inside that.

When this isn't the right fit

  • A public API for browsers: AWS_IAM needs signed requests. Put real auth in front, such as Amazon Cognito, which a later article in this series covers.
  • Traffic above the cap: if you need more than 20 requests a second, raise reserved concurrency and recompute the ceiling.
  • Custom domains, AWS WAF (web application firewall) or request validation: we didn't need them here. API Gateway or Amazon CloudFront in front of the function adds them, at a price.
  • Slow requests: a handler that takes seconds runs out of free compute first. Use the table above.

Try it yourself

The repo works without an AWS account first. npm run check type-checks, lints, runs the unit and CDK tests, and synthesizes with cdk-nag. Start DynamoDB Local in Docker, then run npm run test:int, npm run measure and npm run free-tier-model.

To deploy, you need a CDK-bootstrapped account. Run npm run deploy -- -c alertEmail=you@example.com, confirm the email, then run npm run smoke with the same credentials.

Clean up

Run npm run destroy. The stack keeps the bookmarks table on purpose, so delete it in the DynamoDB console when you're sure.

Has your own free-tier backend hit the request limit or the compute limit first? I'd like to hear where your real billed duration landed.

This article first appeared on AWS Builder Center. The code is on GitHub at InfraTales/lambda-always-free-api.

Next step

Have the same problem on your stack?

Send the architecture, AWS bill concern, deploy pain, or GenAI reliability issue. I'll find the first real bottleneck and propose a small, reversible fix.

FAQ

Which Lambda free tier limit runs out first?

For a fast API, the 1 million free requests run out long before the 400,000 GB-seconds. At 256 MB, both limits run out together only when the mean billed duration reaches 1,600 ms. The bookmarks API measured between 1.3 and 4.5 ms on a laptop.

Does a Lambda function URL cost anything?

No. The Lambda docs say there are no additional charges for the URL endpoint itself. With auth type NONE, anyone who finds the URL spends your free requests, so the repo uses AWS_IAM and unsigned requests get 403.

What is the most a traffic flood can cost?

With 2 reserved copies at 256 MB, the worst possible month is $23.04. Reserved concurrency caps how many copies run at once, and a function URL accepts at most 10 times that many requests a second. Callers above either limit get HTTP 429.

Related reading

#aws-lambda#serverless#amazon-dynamodb#aws-free-tier#best-practices
Rahul Ladumor

Rahul Ladumor

Principal Cloud & AI Platform Architect. AWS Professional certified, 5x AWS Community Builder. I work with teams that have real users, real AWS bills, and real production pressure.

About Rahul →