Serverless Functions on AWS Lambda: Building APIs Without Servers

I have been running a small Express server on a $5/month VPS for a side project. It works fine, but I got curious about serverless after hearing so many people talk about it. So I decided to rebuild the same API on AWS Lambda and see how it compared.
The short version: it works, it is cheap for low traffic, but the developer experience takes some getting used to.
How Lambda Functions Work
A Lambda function is just a function. You write a handler that receives an event and a context object, do your work, and return a response. AWS manages the server, the scaling, and the runtime. You never think about the machine your code runs on.
exports.handler = async (event, context) => {
const body = JSON.parse(event.body);
// Do your work here
const result = await processData(body);
return {
statusCode: 200,
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ data: result })
};
};
The event object contains everything about the incoming request: headers, query parameters, the body, path parameters. The context object has metadata about the invocation itself, like the remaining execution time and the function name.
The return value follows a specific shape. You set the status code, headers, and a stringified body. It feels a bit different from Express where you call res.json(), but you get used to it quickly.
API Gateway
Lambda functions do not handle HTTP requests on their own. You need API Gateway sitting in front of them to route incoming HTTP requests to the right function. You define your routes (GET /users, POST /users, etc.) and API Gateway maps each route to a Lambda function.
API Gateway also handles things like CORS headers, request validation, and API key management. It adds some latency (usually 5 to 15 milliseconds), but for most applications that is negligible.
The Cold Start Problem
This is the thing nobody warns you about until you experience it. When a Lambda function has not been invoked recently, AWS has to spin up a new execution environment. That includes downloading your code, starting the runtime, and running your initialization code. This "cold start" can add anywhere from 100 milliseconds to several seconds to your response time.
For Node.js functions, cold starts are typically 200 to 500 milliseconds. For Java or .NET, they can be much longer. Once the function is warm (the environment stays alive for a while after an invocation), subsequent calls are fast.
There are a few ways to mitigate cold starts. Keeping your deployment package small helps. Avoiding heavy initialization outside your handler helps. AWS also offers Provisioned Concurrency, which keeps a set number of environments warm at all times, but that costs money and partially defeats the purpose of pay-per-invocation pricing.
SAM: The Serverless Application Model
Managing Lambda functions through the AWS console is painful. SAM (Serverless Application Model) makes it much better. It is a CLI tool that lets you define your entire serverless application in a YAML template.
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Resources:
GetUsersFunction:
Type: AWS::Serverless::Function
Properties:
Handler: src/handlers/getUsers.handler
Runtime: nodejs12.x
MemorySize: 128
Timeout: 10
Events:
GetUsers:
Type: Api
Properties:
Path: /users
Method: get
CreateUserFunction:
Type: AWS::Serverless::Function
Properties:
Handler: src/handlers/createUser.handler
Runtime: nodejs12.x
Events:
CreateUser:
Type: Api
Properties:
Path: /users
Method: post
With sam local start-api, you can run your API locally for testing. With sam deploy, you push everything to AWS. It handles creating the API Gateway, the Lambda functions, and the IAM roles.
Connecting to DynamoDB
For storage, I used DynamoDB since it is the natural fit in the AWS serverless ecosystem. It is a NoSQL database that scales automatically and has a pay-per-request pricing mode that pairs well with Lambda.
const AWS = require('aws-sdk');
const dynamo = new AWS.DynamoDB.DocumentClient();
exports.handler = async (event) => {
const params = {
TableName: 'Users',
Item: {
userId: event.pathParameters.id,
name: JSON.parse(event.body).name,
createdAt: new Date().toISOString()
}
};
await dynamo.put(params).promise();
return {
statusCode: 201,
body: JSON.stringify(params.Item)
};
};
The DynamoDB API is different from SQL databases. You think in terms of partition keys, sort keys, and single-table design patterns rather than joins and foreign keys. It took me a while to adjust my mental model, but for simple CRUD operations it works well.
The Cost Math
This is where serverless really shines for small projects. AWS gives you 1 million free Lambda invocations per month. After that, it is $0.20 per million requests plus a small charge for compute time based on memory and duration.
My side project gets about 10,000 requests per month. On Lambda, that costs effectively nothing. My old VPS was $5/month regardless of traffic. For high-traffic applications the math changes, but for anything with bursty or low traffic, serverless pricing is hard to argue with.
What I Would Do Differently
If I were starting over, I would use the Serverless Framework instead of SAM. The community is larger, the plugin ecosystem is richer, and the documentation is more approachable. SAM is fine, but it feels more enterprise-oriented.
I would also think harder about function granularity. I started with one function per route, which is the common pattern. But for a small API, a single function that handles routing internally (using a lightweight router) can be simpler to manage and deploy. You lose some of the isolation benefits, but you gain simplicity.
Serverless is not a silver bullet. The cold start latency is real, debugging distributed functions is harder than debugging a monolithic Express app, and the AWS permission model (IAM) has a steep learning curve. But for the right use case, especially APIs with variable traffic and simple compute needs, it is a compelling option.