This article is a continuation of my previous article, where I built the exact same application using three-tier architecture on AWS and spent $95 to $100 monthly for four EC2 servers, two load balancers, and a NAT gateway, which mainly burned cash. This article is the second half of that experiment. I took the same application and rebuilt it using Lambda, API Gateway, and the same DynamoDB tables. I didn’t use EC2. I didn’t use load balancers. I didn’t use NAT Gateway. I didn’t use a VPC either. What’s the price tag so far? Zero dollars. I have personally checked Cost Explorer several times, and Lambda doesn’t show any charges. The free tier provides you with 1 million requests a month, and our contact app will never reach that number.
However, the result was not a clean, seamless transition. Each side (frontend and backend) took around two hours to deploy. I worked on both sides concurrently. There were also unexpected failures. Some of my assumptions from the three-tier deployment were still applicable but turned out to be incorrect, particularly regarding the scalability and performance expectations that I had initially set. Also, the overall design ended up being completely different than the typical “classic” serverless example you’ll see in most tutorials. This article will cover all of those areas.
Audience for This Article
This article aims to reach the same audience as the previous one. If you read the last article and wanted to know how the serverless option would really appear in practice, this article is the follow-up. If you are a solo developer or member of a small development group who is deciding whether to go with EC2 (Elastic Compute Cloud) or Lambda (a serverless computing service) for a lower traffic site, the cost comparison will likely be valuable.
The Overall Design Was Different Than I Thought It Would Be
The classic serverless design uses CloudFront in front of an S3 bucket that serves a static frontend, with API Gateway and Lambda used for the backend. The design is simple, straightforward, and easy to understand.
That is not what I created.
I used Nuxt 4 for my frontend, complete server-side rendering. Therefore, I can’t place the frontend as static files in an S3 bucket. I need a server to generate new pages each time a request is generated. As such, I placed the frontend in its own Lambda function, with its own API Gateway (HTTP API v2). The backend is another Lambda function running Express 5 via serverless-http, with its own API Gateway (REST API).
One Lambda for frontend, one Lambda for backend. Two API Gateways. No S3. No CloudFront. The overall design evolved to fit the actual needs of the application rather than just to fit the image shown in the tutorial.
Here is a picture of the three-tier architecture from my previous article:
The differences are obvious. The system lacks a VPC, subnets, NAT gateways, and load balancers. There are fewer components, less configuration, and fewer items that will consume resources while they are idle. The frontend Lambda generates all incoming requests, runs Nuxt's SSR, manages authentication via server-side session, and proxies all /api/ calls to the backend API Gateway. The backend Lambda processes the Express route, validates JWT, and operates on the DynamoDB database. Both the frontend and backend Lambdas log their operations to CloudWatch.
Deploying the Backend Was the Easy Part
I started both deployments simultaneously around 11am. The backend was much easier to deploy since Express was designed to work as a request/response framework. I installed serverless-http to wrap Express for use with AWS Lambda. I then created a simple entry point for the backend, exporting the handler. The only additional coding required was to guard the app.listen() method so it wouldn't run locally on Lambda, where it has no port to bind to.
The first problems I encountered were related to CloudFormation. My DynamoDB tables were already defined as part of the three-tiered deployment I completed earlier using DynamoDB. However, Serverless Framework uses CloudFormation to deploy the application, and CloudFormation doesn't support adopting existing resources. Therefore, my initial deployments to the backend simply failed. To resolve this, I removed the resources block from the backend serverless.yml file and left a note indicating that the DynamoDB tables are managed externally. This procedure was not an ideal solution; however, it resolved the problem.
Next, I encountered issues related to CORS. The frontend had initially thrown errors because the backend was preventing requests from the frontend's API Gateway URL. I hardcoded the frontend's API Gateway URL as the allowed origin. Although this resolved the issue for approximately 30 minutes, the issue returned after I redeployed the frontend and API Gateway reassigned a new domain for the frontend.
Lastly, I experienced a 502 error while debugging the backend that consumed significantly more time than I care to admit to resolve. The API Gateway was returning generic 502s without any relevant information. After some investigation, I determined that Dynamoose calls DescribeTable on each cold start to validate the schema of the database, and my IAM role didn't include this permission. In addition, I also required BatchWriteItem, which was missing. I added both of these permissions, and the backend began functioning properly.
Time to deploy the backend: approximately two hours. However, I spent most of that time waiting for CloudFormation and resolving the CORS issue.
The Frontend Deployment Was a Different Story
The frontend deployment was very frustrating. I assumed it would be similar to how the tutorials described: build Nuxt, upload to S3, and add CloudFront in front. Unfortunately, my assumptions quickly fell apart due to the fact that my app utilizes server-side rendering.
At 11:13am, my first attempt to deploy the frontend had serveStatic set to false, assuming S3 would handle static assets. However, I never actually configured S3. Therefore, although the HTML would render, none of the JavaScript or CSS would follow. The frontend was broken from the start.
After the above failure, I encountered the /prod problem. API Gateway v1 (REST API) includes a /prod stage by default. Therefore, my app was being served at something such as, https://xxxx.execute-api.us-east-1.amazonaws.com/prod/ but Nuxt's router had no idea it was mounted at /prod. Each internal link, asset path, and navigation action referenced / rather than /prod. The entire app was therefore broken.
I spent about 40 minutes attempting to solve this problem using environment variables. I added NUXT_APP_BASE_URL and NITRO_APP_BASE_URL, hoping to inform the app regarding the /prod prefix. None of it worked properly. I was addressing the symptoms instead of the root cause.
Ultimately, the correct resolution was to switch from API Gateway v1 to HTTP API v2. v2 does not automatically append a /prod stage prefix. As soon as I switched to version 2, the routing problem ceased to exist. I deleted all of the environment variable settings for NUXT_APP_BASE_URL and NITRO_APP_BASE_URL because they were no longer necessary.
However, the static assets were still broken. I had set serveStatic to false in the Nuxt configuration and never restored it. Additionally, I had to delete @nuxt/content from the modules list because it was either bloating the Lambda package or causing build failures. Finally, my serverless.yml configuration had excluded everything except .output/ to remain below the 250MB size limit for Lambda packages.
Finally, at 1:03 pm, I resolved the last issue by changing serveStatic to true. When set to true, Nitro will serve static assets directly from the Lambda function instead of looking to an external CDN or S3 bucket for them. At that point, the HTML rendered, the JavaScript loaded, the CSS applied, and the app functioned.
It took nearly two hours of commits, reverts, and incorrect turns to arrive at a two-line configuration change.
Cold Starts Are Real
The three-tier setup did not have a cold start problem. In a three-tier setup using EC2 instances, all EC2 instances are always running and therefore always “warm.” As a result, response times will always be consistent. With AWS Lambda, however, the first request after a certain period of inactivity will take a couple of seconds longer than subsequent requests. On a cold start, my frontend Lambda (which is running Nuxt SSR) typically takes a couple of seconds longer than normal to respond, usually 2–3 seconds. My backend Lambda (which is an Express.js function) generally responds in under a second. Once a request hits the Lambda function, the next requests will be faster.
For my contact app, the two-second delay caused by a cold start is acceptable. People aren’t constantly refreshing the contact app; they enter once and then leave. For apps that need to operate with precision and require every millisecond possible, this type of cold-start delay could be unacceptable. For example, if an e-commerce checkout page took 3 seconds to load upon the first attempt, most people wouldn't stick around for a second try.
There are several ways to mitigate the effects of a cold start. One of them is provisioned concurrency. Provisioned concurrency keeps your Lambdas running, but it increases the cost associated with serverless computing and essentially defeats part of the reason you chose to use serverless computing. Another way is to ping your function(s) periodically to keep them active; this method is sort of a workaround/hack.
I simply allowed my Lambdas to go dormant. The occasional 2-second delay due to a cold start wasn’t enough of a problem for what this app does.
The Cost Comparison Speaks for Itself
Three-tier (from my previous article):
| Component | Monthly Cost |
| 2x t3.micro (web tier) | ~$15 |
| 2x t3.micro (app tier) | ~$15 |
| ALB (external) | ~$16 |
| ALB (internal) | ~$16 |
| NAT Gateway | ~$32+ |
| DynamoDB (on demand) | ~$1 to $5 |
| Total | ~$95 to $100 |
Serverless:
| Component | Monthly Cost |
| Lambda (frontend + backend) | $0 (free tier) |
| API Gateway (HTTP API v2 + REST API) | $0 (free tier) |
| DynamoDB (on demand) | ~$1 to $5 |
| CloudWatch (logs) | ~$0 to $1 |
| Total | ~$1 to $6 |
That is not a typo. Serverless versions cost about $1-$6 a month versus $95-$100. As I currently generate very little traffic, Lambda and API Gateway do not charge me anything. I am using the free tier for all of them.
The three-tier architecture had a floor. Auto Scaling groups cannot be scaled to 0. So four EC2 servers ran 24/7 regardless if someone was using the app or not. Two load balancers charged their monthly rate regardless of the amount of traffic. The NAT Gateway billed me simply for existing, even when almost no traffic was passing through it.
Serverless has no floor. If there are zero requests, there is zero compute cost. This benefit is the main difference for apps with little traffic.
Something to consider: API Gateway’s free tier (1,000,000 requests per month) only applies for the first year for new AWS accounts. After that, HTTP API v2 charges $1 per 1,000,000 requests, and REST API charges $3.50 per 1,000,000 requests. For my traffic, this would result in an additional few pennies per month. Although this figure is much lower than $95, it is worth considering before assuming serverless is free forever.
What I Would Say to Someone Who Is Deciding Between These Two
If I had always chosen serverless, I wouldn't have paid the complete three-tier bill and spent the time to set up VPCs, subnets, security groups, and NACLs. The migration caused friction; however, the operational complexity was measured in hours rather than the days it took to set up the initial three-tier infrastructure.
However, serverless also created friction. Debugging is more difficult. When the backend threw 502s, I had no SSH access to determine what was occurring. I had to dig through CloudWatch logs, which are not necessarily user-friendly. Cold starts are real and noticeable. Additionally, the final architecture proved to be more complex than the "simple" Lambda example because I needed to use SSR for my frontend.
The three-tier setup gives you control and predictability at the expense of money and operational overhead. Serverless provides you with cost efficiency at the expense of debugging visibility and some performance unpredictability, which can complicate troubleshooting and impact user experience during peak usage times.
My contact app obviously favors serverless. The cost savings alone made the decision simple. However, I would not make the same choice for every application. Applications that require high traffic, consistent loads, persistent connections, or long-running processes are still better suited to run on EC2 or containers.
Key Takeaways From the Experiment
- Architecture patterns should be selected based on workload characteristics rather than tradition.
- Serverless significantly reduces the baseline infrastructure cost for applications with low traffic.
- Debugging is more difficult in serverless architectures.
- Applications requiring high throughput or persistent connections are still best suited to run on EC2 or container-based solutions.
Reflecting on Both Articles
The reason I did this two-article exercise was to develop the ability to judge when to choose what. I didn’t know, going into this, how big the cost difference would be. I did not anticipate the architectural differences to be as large as they ended up being. And I definitely did not anticipate spending 40 minutes trying to get the /prod prefix to resolve.
But that is exactly why I did it. There is a huge difference between reading about the benefits of serverless and EC2 and actually deploying both to compare the invoices.
This experiment reaffirmed a thought process I’ve been developing over the last couple of years. Architecture choices are constraint-driven and not pattern-driven. The three-tier model was not incorrect. It was incorrect for this application, this level of traffic, and this team size. Serverless was not the automatic correct answer either. It was correct because the constraints pointed to it. Another application with a different set of requirements can easily justify the opposite choice, such as a scenario where latency is critical or where specific compliance regulations must be met. The value isn't in choosing a side. The value is in understanding why you chose that side.
Maturity in architecture is less about knowing the patterns and more about knowing when those patterns cease to serve your constraints.
You can find the full project and deployment configuration on GitHub.
