Serverless: Lambda, API Gateway and DynamoDB
Run code without servers and pay only when it runs. How Lambda works and what limits it, cold starts, DynamoDB tables and their keys, API Gateway as the front door, and a hands-on where you build a working API.
- Key idea
- Definition
- When to use
- Caution
In this article
In part twenty we stopped managing servers, but tasks still run around the clock, waiting for visitors who may never come. What if your code only ran when a request arrived, and you paid nothing otherwise?
That's serverless. There are still servers, of course, but provisioning, updating and scaling them are no longer your problem.
| EC2 | Fargate | Lambda | |
|---|---|---|---|
| You manage | The system and the application | The image and the application | The code |
| You pay for | Running time | Running time | Each request and its duration |
| When idle | You pay | You pay | Almost nothing |
| Scaling | You set it up | You set it up | By itself |
| Runs for | As long as you like | As long as you like | Up to 15 minutes per request |
AWS Lambda
Lambda runs a function you write when an event triggers it. It does its work, then stops.
It supports Python, Node.js, Java, .NET and Ruby directly, and any other language through a custom runtime or a container image. This is what a function looks like in Python:
def handler(event, context):
name = event.get("name", "world")
return {"message": f"Hello {name}"}
The event carries the details of what happened, and its shape depends on the source: an S3 event contains the bucket and the key, for example, and an API event contains the path and the headers.
What does it cost?
You pay for the number of requests, and for the duration multiplied by memory, in GB-seconds. Prices below are for x86 in US Regions, and the free tier covers 1 million requests and 400,000 GB-seconds every month.
An example: a function with 512 MB of memory that runs for 200 milliseconds, 5 million times a month:
| Item | Calculation | Cost |
|---|---|---|
| Duration | 5,000,000 x 0.2 s x 0.5 GB = 500,000 GB-s, minus 400,000 free | $1.67 |
| Requests | 5 million minus 1 million free, at $0.20 per million | $0.80 |
| Total | about $2.47 |
Five million requests for less than a coffee. But the calculation can flip: with heavy, constant load, a server or container can come out cheaper, so do the math.
Limits to know
| Limit | Value |
|---|---|
| Memory | 128 MB to 10,240 MB, and CPU rises with it |
| Duration | 15 minutes per request |
| Temporary storage | /tmp, from 512 MB to 10 GB |
| Package size | 50 MB zipped, 250 MB unzipped, or a 10 GB container image |
| Concurrency | 1,000 per Region by default, and it may be lower in new accounts |
| Payload | 6 MB for synchronous calls, 1 MB for asynchronous |
If your work takes longer than 15 minutes, split it up with Step Functions, or use a container.
Concurrency
Each execution environment serves one request at a time, so 100 simultaneous requests need 100 environments. That number is your concurrency, and all functions in a Region share one limit. What happens when you reach it?
- Synchronous calls, as from an API, get a
429error. - Asynchronous calls, as from S3, are held and retried for up to 6 hours, then sent to a dead-letter queue or a failure destination if you've set one.
The hidden trap is that one busy function can consume the whole limit and throttle your others. Reserved concurrency sets aside a share for a function, which is also a ceiling for it, so it neither gets starved nor starves others.
Cold starts
When a request arrives with no environment ready, Lambda creates one and loads your code first. This is a cold start, and it may add from a fraction of a second to several seconds, depending on the language and the size of the code. Then the environment stays around for a while and serves later requests quickly. This setup time is billed too, since August 2025.
| Tool | How it works | Cost |
|---|---|---|
| Provisioned Concurrency | Keeps a number of environments ready at all times, so there's no cold start for them | You pay for them while ready |
| SnapStart | Saves a snapshot after setup and restores from it, for Java 11+, Python 3.12+ and .NET 8+ | Free for Java; charged for Python and .NET |
The two can't be combined on one function. SnapStart lowers startup time but doesn't guarantee to remove it, so if you need very low, steady latency, use Provisioned Concurrency.
Lambda inside a VPC
By default a function runs outside your VPC, so it can reach the internet and public AWS services, but not your private RDS. Attach it to your VPC and it can reach them, with two things to note:
- It loses its internet access, unless the subnet goes through a NAT gateway or you use endpoints.
- A thousand environments can open a thousand connections to the database. This is what RDS Proxy solves, by reusing a few connections.
Code at the edge
We mentioned two ways of running short code on CloudFront. Here's the difference:
| CloudFront Functions | Lambda@Edge | |
|---|---|---|
| Languages | JavaScript | Node.js and Python |
| Duration | Under a millisecond | Up to 30 seconds on origin events |
| Network access | No | Yes |
| Events | Viewer request and response | Those, plus origin request and response |
| Good for | Redirects, headers and cache keys | Heavier logic and calling other services |
Amazon DynamoDB
Lambda scales in seconds to thousands of copies, and a traditional database doesn't like that. DynamoDB is a serverless key-value database: no servers, no connections to manage, millisecond responses at any size, and data replicated across several Availability Zones automatically.
Tables, items and keys
A table contains items, each with attributes, and two items don't need to have the same attributes. The maximum size of an item is 400 KB. What's fixed is the primary key:
customer (partition key) |
order_id (sort key) |
total |
status |
coupon |
|---|---|---|---|---|
| sara | 1001 | 120 | shipped | |
| sara | 1002 | 40 | new | SAVE10 |
| omar | 1003 | 75 | new |
- Partition key: decides where the item is stored. Choose one with many varied values, so that the load spreads out.
- Sort key: optional, and it orders items sharing the same partition key, so you can ask for "Sara's latest orders".
This is the big difference from relational databases: there are no joins, and you fetch items by their key. So start from your queries and design the table to suit them. If you need to query by another attribute, such as status, add a global secondary index.
Capacity modes
| Mode | How it works | When |
|---|---|---|
| On-demand | Scales by itself, and you pay per request | Unknown or changing load; start here |
| Provisioned | You set reads and writes per second, optionally with auto scaling | Steady, predictable load, which comes out cheaper |
Provisioned capacity is counted in units. One read capacity unit (RCU) is one strongly consistent read per second of an item up to 4 KB, or two eventually consistent ones. One write capacity unit (WCU) is one write per second of an item up to 1 KB. So reading 80 items of 3 KB per second with strong consistency needs 80 RCUs, and writing 100 items of 512 bytes per second needs 100 WCUs.
Additions worth knowing
| Feature | What it does |
|---|---|
| DAX | A cache in front of the table, taking reads from milliseconds to microseconds |
| Streams | A log of every change, kept for 24 hours, which can trigger Lambda |
| Global Tables | Copies in several Regions, all accepting writes |
| TTL | Automatically deletes items after a time you set, such as old sessions |
| Point-in-time recovery | Restores the table to any second within the last 1 to 35 days |
| S3 integration | Exports a table to S3 for analysis, or imports from S3 into a new table |
Where does DAX stand against ElastiCache? DAX is a transparent cache for DynamoDB, with almost no code change. ElastiCache is more general, for example for storing calculated results that your application manages itself.
Global Tables are eventually consistent by default, so a write in one Region reaches the others after a short delay. If you need a read in any Region to see the latest write, there's a strong consistency mode, which needs exactly three Regions and has some limitations.
API Gateway: the front door
A function has no web address by default. API Gateway receives the request over HTTPS and passes it on to the function, handling on your behalf what you don't want to write yourself: authentication, throttling, versions and more.
| Type | Suits |
|---|---|
| HTTP API | Most APIs: simpler and cheaper, with a fixed 30-second limit |
| REST API | More features: caching, API keys and request validation |
| WebSocket API | Two-way connections, such as chat |
A REST API also comes in three kinds: Edge-optimized through CloudFront for users around the world, Regional for users close by, and Private for inside your VPC only.
Behind it you can put a Lambda function, any HTTP address, or an AWS service directly. For instance, it can send a request straight to SQS, with no function in between. And for a single simple endpoint, Lambda also offers function URLs: a direct HTTPS link to the function, without these extras.
Who can call your API?
| Method | Suits |
|---|---|
| IAM | Your own services and applications inside AWS |
| Cognito | Your application's users; we'll cover it in the next part |
| Lambda authorizer | Your own logic, such as a token from another system |
Throttling
API Gateway protects what's behind it by limiting requests, and it applies its limits from the most specific down: per client, per method, then for the whole account in the Region. Past the limit, the client gets a 429 and can retry. On a REST API, usage plans with API keys give each client its own rate and quota, which suits an API you sell.
Hands-on: a working API
We'll build a page visit counter from the diagram's three pieces. Everything here falls within the free tier for an experiment like this. From CloudShell, create the table:
aws dynamodb create-table --table-name series-visits \
--attribute-definitions AttributeName=page,AttributeType=S \
--key-schema AttributeName=page,KeyType=HASH \
--billing-mode PAY_PER_REQUEST \
--query TableDescription.TableStatus
aws dynamodb wait table-exists --table-name series-visits
TABLE_ARN=$(aws dynamodb describe-table --table-name series-visits \
--query Table.TableArn --output text)
Give the function a role that can write logs and update this table only:
cat > trust.json <<'EOF'
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {"Service": "lambda.amazonaws.com"},
"Action": "sts:AssumeRole"
}]
}
EOF
ROLE_ARN=$(aws iam create-role --role-name series-visits-role \
--assume-role-policy-document file://trust.json \
--query Role.Arn --output text)
aws iam attach-role-policy --role-name series-visits-role \
--policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
aws iam put-role-policy --role-name series-visits-role \
--policy-name table-access --policy-document "{
\"Version\": \"2012-10-17\",
\"Statement\": [{
\"Effect\": \"Allow\",
\"Action\": \"dynamodb:UpdateItem\",
\"Resource\": \"$TABLE_ARN\"
}]
}"
Write the function. It adds one to the counter for the requested path, and an update like this is safe even with simultaneous requests:
cat > app.py <<'EOF'
import json
import os
import boto3
table = boto3.resource("dynamodb").Table(os.environ["TABLE"])
def handler(event, context):
page = event.get("rawPath", "/")
result = table.update_item(
Key={"page": page},
UpdateExpression="ADD visits :one",
ExpressionAttributeValues={":one": 1},
ReturnValues="UPDATED_NEW",
)
visits = int(result["Attributes"]["visits"])
return {
"statusCode": 200,
"headers": {"content-type": "application/json"},
"body": json.dumps({"page": page, "visits": visits}),
}
EOF
zip -q function.zip app.py
Create the function. A new role takes a few seconds to take effect, so if you get an error about the role, wait a little and repeat the command:
FN_ARN=$(aws lambda create-function --function-name series-visits \
--runtime python3.13 --handler app.handler --role $ROLE_ARN \
--zip-file fileb://function.zip \
--environment "Variables={TABLE=series-visits}" \
--query FunctionArn --output text)
aws lambda wait function-active-v2 --function-name series-visits
Now the front door. This command creates an HTTP API that sends every path to the function, and then we allow it to invoke the function:
API_ID=$(aws apigatewayv2 create-api --name series-visits \
--protocol-type HTTP --target $FN_ARN \
--query ApiId --output text)
ACCOUNT=$(aws sts get-caller-identity --query Account --output text)
aws lambda add-permission --function-name series-visits \
--statement-id api-gateway --action lambda:InvokeFunction \
--principal apigateway.amazonaws.com \
--source-arn "arn:aws:execute-api:$AWS_REGION:$ACCOUNT:$API_ID/*" \
--query Statement --output text > /dev/null
URL=$(aws apigatewayv2 get-api --api-id $API_ID \
--query ApiEndpoint --output text)
echo $URL
Try it. You can also open the link in your browser:
curl -s $URL/home; echo
curl -s $URL/home; echo
curl -s $URL/about; echo
The counter goes up: home reaches 2 and about is at 1. Look at the data in the table:
aws dynamodb scan --table-name series-visits \
--query "Items[].[page.S,visits.N]" --output table
Finally, see the cold start in the function's logs:
aws logs tail /aws/lambda/series-visits --since 10m | grep REPORT
Only the first request has an Init Duration field, which is the time taken to set up the environment, and the requests after it are much faster. If the logs are empty, wait a few seconds and try again.
aws apigatewayv2 delete-api --api-id $API_ID
aws lambda delete-function --function-name series-visits
aws dynamodb delete-table --table-name series-visits \
--query TableDescription.TableStatus
aws logs delete-log-group --log-group-name /aws/lambda/series-visits
aws iam delete-role-policy --role-name series-visits-role \
--policy-name table-access
aws iam detach-role-policy --role-name series-visits-role \
--policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
aws iam delete-role --role-name series-visits-role
Summary
- Serverless means you write code and data, and pay only for use.
- Lambda runs up to 15 minutes, and you pay per request and per GB-second.
- Cold starts are eased with Provisioned Concurrency or SnapStart, and reserved concurrency protects functions from each other.
- DynamoDB fetches items by key, so design the table around your queries.
- API Gateway is the front door, handling authentication and throttling; start with an HTTP API.
Test yourself
Before you move on, try these questions to check you've got this part. Each one is a practical scenario, and the explanation appears once you answer.
Question 1 of 6
A Lambda function is attached to a VPC so it can reach a private RDS database. That works, but now its calls to a payment provider on the internet time out. What fixes this?
Show the answer
Correct answer: C
Once attached to a VPC, a function loses its internet access unless its subnet goes through a NAT gateway.
- A: A public subnet gives the function neither internet access nor a public address.
- B: There's no path to the internet, so more time won't help.
- D: This handles database connections, and the problem is the path out.
Review: Lambda inside a VPC in this part, and Enable internet access for VPC-connected Lambda Event Functions in the AWS docs.
Question 2 of 6
An orders table has customer as its partition key and order_id as its sort key. The team now needs all orders whose status is new, quickly and often. What should it do?
Show the answer
Correct answer: D
A global secondary index organizes the data by a different key, so you can query by status directly.
- A: This reads every item each time, so it gets slower and costlier as the table grows.
- B: DynamoDB has no joins.
- C: This speeds up reads you can already do, and doesn't add a new way to query.
Review: Tables, items and keys in this part, and Using Global Secondary Indexes in DynamoDB in the AWS docs.
Question 3 of 6
A product page reads the same DynamoDB items millions of times a day. The team wants reads to take microseconds, with the fewest code changes. What fits?
Show the answer
Correct answer: A
DAX is a cache in front of the table that works with DynamoDB's API, so it takes only minimal changes.
- B: This can be fast too, but your application has to manage what goes in and out of the cache.
- C: This places copies in several Regions, and doesn't bring reads down to microseconds.
- D: This is a log of changes, and doesn't speed up reads.
Review: Additions worth knowing in this part, and In-memory acceleration with DynamoDB Accelerator (DAX) in the AWS docs.
Question 4 of 6
A company is selling access to its API. It needs API keys for each customer, throttling per client, and cached responses. Which option fits?
Show the answer
Correct answer: B
REST APIs have all of these. HTTP APIs are simpler and cheaper because they leave such features out.
- A: This has neither API keys nor caching.
- C: This is for two-way connections, such as chat.
- D: This is a direct link to a function, without any of these extras.
Review: API Gateway: the front door in this part, and Choose between REST APIs and HTTP APIs in the AWS docs.
Question 5 of 6
A report function sometimes uses up the Region's whole concurrency limit, and the checkout function starts returning 429. Checkout also has to answer with steady, very low latency. Which two settings fit the checkout function? (Choose two.)
Show the answer
Correct answer: A + C
Reserved concurrency sets aside a share for checkout that no other function can take, and Provisioned Concurrency keeps environments ready so they have no cold start.
- B: This lowers startup time without guaranteeing to remove it, and can't be combined with Provisioned Concurrency.
- D: This adds temporary disk space, and has nothing to do with either problem.
- E: CPU rises with memory, so this would make responses slower.
Review: Concurrency and Cold starts in this part, and Configuring reserved concurrency for an Event Function and Configuring provisioned concurrency for an Event Function in the AWS docs.
Question 6 of 6
An application reads 50 items per second from a DynamoDB table in provisioned mode. Each item is 6 KB, and the reads must be strongly consistent. How many read capacity units does it need?
Show the answer
Correct answer: C
One RCU covers one strongly consistent read per second of up to 4 KB, so a 6 KB item needs two. Fifty reads per second need 100.
- A: This counts each read as half a unit, which doesn't cover a 6 KB item.
- B: This would be enough if the same reads were eventually consistent.
- D: This multiplies by the item's size, while units are counted per 4 KB.
Review: Capacity modes in this part, and DynamoDB provisioned capacity mode in the AWS docs.
You now have the building blocks. How do you build a complete application from them, with users, long workflows and events flowing between the services? That's what we'll cover in part twenty-two.