architectureJanuary 15, 202612 min read

Building Zero-Cost APIs with AWS Lambda - A Practical Guide

AWS Lambda Serverless API Gateway Cost Optimization

The Problem That Started It All

Four years ago, I made a mistake that cost about $2,400 in unnecessary AWS charges.

I had spun up a small EC2 instance to host an internal tool—a simple dashboard to track deployment statuses. It worked fine. Then project priorities shifted, and that little t2.micro sat there running 24/7 for eight months, serving approximately zero requests.

When I finally noticed it during a cost audit, I felt ridiculous. I was paying $15/month for something nobody used. Multiply that across the other "temporary" instances that had been created over the years, and suddenly there was a meaningful line item for infrastructure that did nothing.

That experience changed how I think about architecture for internal tools, MVPs, and low-traffic services.

The Revelation: Pay Only for What You Use

The core insight of serverless is simple: why pay for idle compute?

Traditional infrastructure charges you whether you're handling 10,000 requests or zero. Serverless flips this—you pay per request, per millisecond of compute time. For workloads with unpredictable or modest traffic, this fundamentally changes the economics.

AWS Lambda's free tier makes this even more compelling:

  • 1 million requests per month — free
  • 400,000 GB-seconds of compute — free
  • This isn't a 12-month trial — it's permanent

For a typical function with 128MB memory, that compute allowance translates to roughly 3.2 million seconds of execution time per month. That's a lot of headroom.

The Architecture That Costs Nothing

When I built an internal API for tracking project metrics, I had one constraint: it had to cost $0 when idle and stay within free tier during normal usage.

The architecture I landed on:

CloudFront (caching + custom domain)
    ↓
Lambda Function URL (free HTTP endpoint)
    ↓
Lambda Function (business logic)
    ↓
DynamoDB (storage)
Every component in this stack has a free tier that covers the usage. Let me walk through the decisions.

Why Lambda Function URLs Instead of API Gateway

This was the key insight. API Gateway is the "obvious" choice for Lambda-backed APIs, but it costs $3.50 per million requests. For a zero-cost architecture, that's disqualifying. Lambda Function URLs, introduced in 2022, provide a free HTTPS endpoint directly to your function. No API Gateway required. The tradeoff: you lose API Gateway's built-in features like request validation, usage plans, and API keys. For internal tools and simple APIs, I've found these are rarely needed—and when they are, you can implement them in your Lambda code.

Why DynamoDB Instead of RDS

This decision comes down to one word: VPC. If your Lambda needs to talk to RDS, it needs to be in a VPC. If it's in a VPC and needs internet access (for external APIs, etc.), you need a NAT Gateway. NAT Gateway costs ~$32/month minimum. DynamoDB requires no VPC connectivity. Lambda talks to it over AWS's internal network. Zero additional infrastructure cost. The tradeoff: DynamoDB has a learning curve if you're coming from SQL. Single-table design, partition keys, query patterns—it's different. But once you internalize the model, it's remarkably powerful.

Why CloudFront in Front

CloudFront serves three purposes:
  1. Custom domain — Lambda Function URLs have ugly AWS-generated hostnames
  2. Caching — Reduces Lambda invocations for repeated requests
  3. Free SSL — HTTPS with AWS Certificate Manager
CloudFront's free tier includes 1TB of data transfer and 10 million requests monthly. For an internal tool, that's effectively unlimited.

The Pseudocode Pattern

Every function in this architecture follows the same pattern:
FUNCTION handleRequest(httpEvent):
    TRY:
        // Parse the incoming request
        method = httpEvent.method
        path = httpEvent.path
        body = parseJson(httpEvent.body)
        
        // Route to appropriate handler
        IF method == "GET" AND path == "/metrics":
            result = fetchMetricsFromDynamoDB()
        ELSE IF method == "POST" AND path == "/metrics":
            result = saveMetricsToDynamoDB(body)
        ELSE:
            RETURN response(404, "Not found")
        
        // Return success with caching headers
        RETURN response(200, result, cacheFor: 60 seconds)
        
    CATCH error:
        logError(error)
        RETURN response(500, "Internal error")
For DynamoDB operations:
FUNCTION fetchMetricsFromDynamoDB():
    query DynamoDB:
        table = "Metrics"
        partitionKey = "PROJECT#main"
        sortKey BEGINS_WITH "METRIC#"
    
    RETURN transform results to API format

FUNCTION saveMetricsToDynamoDB(data):
    put item to DynamoDB:
        table = "Metrics"
        item = {
            partitionKey: "PROJECT#main",
            sortKey: "METRIC#" + generateId(),
            name: data.name,
            value: data.value,
            timestamp: now()
        }
    
    RETURN success confirmation

Real Numbers from Production

My internal metrics API has been running for 14 months. Here's the actual usage and cost:
MonthRequestsLambda TimeDynamoDB ReadsCost
Q1 Avg23,4001,840 sec31,000$0.00
Q2 Avg41,2003,210 sec54,000$0.00
Q3 Avg58,9004,580 sec78,000$0.00
Q4 Avg67,3005,120 sec91,000$0.00
I'm using less than 7% of Lambda's free tier and about 0.4% of the compute allowance. I could 10x the traffic before paying anything. The CloudFront caching is doing significant work—about 35% of requests never hit Lambda because they're served from cache.

The Gotchas I Learned the Hard Way

Cold Starts Are Real

The first request after a period of inactivity takes 1-3 seconds instead of 100ms. Lambda needs to initialize your function—download the code, start the runtime, run your initialization logic. For an internal tool where users can wait a couple seconds, this is fine. For a customer-facing API where latency matters, you'd need Provisioned Concurrency (which costs money) or a different architecture. What I do: Keep functions small. Use Node.js or Python (faster cold starts than .NET or Java). Accept the tradeoff for internal tools.

Logs Can Sneak Up On You

CloudWatch Logs ingestion is free up to 5GB/month. But here's the thing: logs are stored forever by default. Storage costs accumulate. I learned this when I noticed $3.47 in CloudWatch charges after six months. Not much, but it violated my "zero cost" principle. The fix:
Set log retention policy:
    log group = /aws/lambda/my-function
    retention = 14 days
Also: don't log every request. Log errors and important events. Your future self debugging at 2am will still have what they need.

The VPC Trap

I mentioned this earlier, but it's worth emphasizing: the moment you put Lambda in a VPC, your costs change dramatically. Common reasons people add VPC:
  • Connecting to RDS → Use DynamoDB instead
  • Connecting to ElastiCache → Consider DynamoDB DAX or just accept the latency
  • Connecting to internal services → Use VPC endpoints (some are free)
If you absolutely need VPC, budget $32+/month for NAT Gateway. The zero-cost architecture no longer applies.

API Gateway Muscle Memory

Every tutorial uses API Gateway. It's the "default" choice. You have to consciously choose Lambda Function URLs instead. The mental shift: API Gateway is a product with features you pay for. Lambda Function URLs are just a free HTTP endpoint. For simple APIs, you don't need the product.

When This Architecture Doesn't Work

I want to be clear about the limitations: High traffic: Once you exceed 1M requests/month, you start paying. The costs are still reasonable (~$0.20 per million after free tier), but it's no longer zero. Low latency requirements: Cold starts make sub-200ms P99 latency impossible without Provisioned Concurrency. WebSocket APIs: Lambda Function URLs don't support WebSocket. You'd need API Gateway WebSocket ($1.00 per million messages + connection charges). Complex API features: If you need request throttling, API keys, usage plans, or request/response transformation, API Gateway provides these out of the box. Implementing them yourself in Lambda is possible but adds complexity. Relational data: If your data model genuinely requires SQL, you'll need RDS, which means VPC, which means NAT Gateway costs. Sometimes DynamoDB isn't the right fit.

The Broader Lesson

The zero-cost architecture taught me something about infrastructure decisions: always question the defaults. The "standard" architecture—API Gateway + Lambda + RDS in a VPC—costs $50-100/month minimum before handling a single request. For a startup, an MVP, or an internal tool, that's friction. The architecture I've described costs nothing until you have meaningful traffic. When you do have traffic, costs scale linearly with usage. You're never paying for idle resources. For internal tools, MVPs I'm validating, and side projects, this is now my default starting point. When the requirements exceed what this architecture can handle, I upgrade. But I don't start with the expensive option and scale down—I start simple and scale up.

Setting It Up: The Checklist

If you want to replicate this:
  1. Create your Lambda function with Node.js or Python runtime, 128MB memory
  2. Enable Lambda Function URL with appropriate CORS settings
  3. Create DynamoDB table with on-demand capacity mode
  4. Set up CloudFront distribution pointing to your Lambda URL
  5. Configure log retention to 7-14 days
  6. Create billing alarm to alert if charges exceed $1
The last point is important. Set up the alarm before you deploy. You'll sleep better knowing AWS will tell you if something goes wrong.

Conclusion

That $2,400 mistake four years ago led me to fundamentally reconsider how I architect low-traffic services. The serverless free tier isn't a gimmick—it's a genuine capability that changes the economics of certain workloads. For internal tools, MVPs, side projects, and any service where traffic is modest or unpredictable, this architecture provides production-quality reliability at zero marginal cost. No servers to manage, no idle compute to pay for, automatic scaling when you need it. The constraint of "it must cost nothing when idle" forced better architectural thinking. Sometimes constraints are exactly what you need.
Ivan Kikhtan

Ivan Kikhtan

Full-Stack Engineer & Technical Lead with 5+ years of experience building scalable cloud-native solutions. Passionate about serverless architectures, developer productivity, and sharing knowledge.

Connect on LinkedIn