Part 21 of Learn AWS from Scratch

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.

In this article
  1. AWS Lambda
  2. Amazon DynamoDB
  3. API Gateway: the front door
  4. Hands-on: a working API
  5. Summary
  6. Test yourself

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.

S3 upload SQS message API request Schedule Lambda Your code runs DynamoDB S3 SNS
An event starts the function, it does its work and stops. You pay only for the time it ran.

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:

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 429 error.
  • 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.

App API Gateway Auth, throttling Lambda Your logic DynamoDB Your data
The classic serverless API: every part scales with requests, and with no traffic you pay almost nothing.
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:

Bash
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:

Bash
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:

Bash
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:

Bash
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:

Bash
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:

Bash
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:

Bash
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:

Bash
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.

Bash
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.