Serverless and AWS Lambda: Deploy Code Without Managing Servers
A practical guide to serverless computing with AWS Lambda — functions, API Gateway, DynamoDB, cold starts, pricing, and when serverless is the wrong choice.
"Serverless" is one of the most confusing terms in computing. There are servers — you just don't manage them. You don't provision them, patch them, scale them, or pay for them when they're idle. You write a function, deploy it, and the cloud provider runs it whenever it's triggered. You pay per invocation and per millisecond of execution time. When nobody's using your application at 3 AM, your bill is zero.
That's the pitch, and for a lot of use cases, it actually delivers. AWS Lambda launched in 2014 and it's now the backbone of countless production applications — from startups processing a few hundred requests a day to enterprises handling billions. The economics are compelling, the operational burden is minimal, and the scaling is automatic.
But serverless also has sharp edges that the marketing doesn't mention. Cold starts add latency. Debugging distributed functions is harder than debugging a monolith. Vendor lock-in is real. And for sustained, high-traffic workloads, serverless can be more expensive than a traditional server.
Let's cover all of it — the good, the bad, and the practical.
What Serverless Actually Means
In a traditional deployment, you rent a server (EC2, a VPS, a bare metal machine), install your runtime, deploy your code, and keep the server running 24/7. You pay whether it's processing requests or sitting idle. You handle scaling by adding more servers behind a load balancer.
With serverless, you deploy a function. The cloud provider:
- Allocates compute resources when the function is triggered
- Runs your code
- Returns the result
- Deallocates resources when execution completes
You never think about servers, operating systems, or capacity planning. The provider handles all of it.
AWS Lambda Basics
A Lambda function is a piece of code that runs in response to an event. The event can be an HTTP request, a file upload, a database change, a scheduled timer, or a message from a queue.
# lambda_function.py — the simplest Lambda
import json
def lambda_handler(event, context):
"""
event: the trigger data (HTTP request body, S3 event, etc.)
context: runtime information (function name, memory, time remaining)
"""
name = event.get("name", "World")
return {
"statusCode": 200,
"headers": {"Content-Type": "application/json"},
"body": json.dumps({
"message": f"Hello, {name}!",
"remaining_time_ms": context.get_remaining_time_in_millis()
})
}
A more realistic example — processing an image upload:
import json
import boto3
from PIL import Image
import io
s3 = boto3.client("s3")
def lambda_handler(event, context):
"""Triggered when an image is uploaded to S3. Creates a thumbnail."""
# Get the upload details from the S3 event
bucket = event["Records"][0]["s3"]["bucket"]["name"]
key = event["Records"][0]["s3"]["object"]["key"]
# Skip if it's already a thumbnail
if key.startswith("thumbnails/"):
return {"statusCode": 200, "body": "Skipped — already a thumbnail"}
# Download the original image
response = s3.get_object(Bucket=bucket, Key=key)
image_data = response["Body"].read()
# Create thumbnail
image = Image.open(io.BytesIO(image_data))
image.thumbnail((300, 300))
# Save thumbnail
buffer = io.BytesIO()
image.save(buffer, format="JPEG", quality=85)
buffer.seek(0)
thumbnail_key = f"thumbnails/{key}"
s3.put_object(
Bucket=bucket,
Key=thumbnail_key,
Body=buffer,
ContentType="image/jpeg"
)
return {
"statusCode": 200,
"body": json.dumps({
"original": key,
"thumbnail": thumbnail_key
})
}
This function runs automatically whenever an image is uploaded to an S3 bucket. No server to maintain. No polling for new uploads. AWS triggers the function, runs it, and bills you for the execution time.
API Gateway — HTTP Endpoints for Lambda
API Gateway sits in front of Lambda and exposes your functions as HTTP endpoints:
# A REST API with multiple endpoints
import json
import boto3
from decimal import Decimal
dynamodb = boto3.resource("dynamodb")
table = dynamodb.Table("Products")
def lambda_handler(event, context):
"""Route HTTP requests to the appropriate handler."""
method = event["httpMethod"]
path = event.get("pathParameters", {})
body = json.loads(event.get("body", "{}") or "{}")
try:
if method == "GET" and not path:
return get_all_products()
elif method == "GET" and path.get("id"):
return get_product(path["id"])
elif method == "POST":
return create_product(body)
elif method == "PUT" and path.get("id"):
return update_product(path["id"], body)
elif method == "DELETE" and path.get("id"):
return delete_product(path["id"])
else:
return response(404, {"error": "Not found"})
except Exception as e:
print(f"Error: {e}")
return response(500, {"error": "Internal server error"})
def get_all_products():
result = table.scan()
items = result.get("Items", [])
# Convert Decimal to float for JSON serialization
items = [{k: float(v) if isinstance(v, Decimal) else v
for k, v in item.items()} for item in items]
return response(200, items)
def get_product(product_id):
result = table.get_item(Key={"id": product_id})
item = result.get("Item")
if not item:
return response(404, {"error": "Product not found"})
return response(200, item)
def create_product(body):
import uuid
item = {
"id": str(uuid.uuid4()),
"name": body["name"],
"price": Decimal(str(body["price"])),
"category": body.get("category", "uncategorized")
}
table.put_item(Item=item)
return response(201, {"id": item["id"], "message": "Created"})
def update_product(product_id, body):
update_expr = "SET "
expr_values = {}
expr_names = {}
for key, value in body.items():
safe_key = f"#{key}"
expr_names[safe_key] = key
expr_values[f":{key}"] = Decimal(str(value)) if isinstance(value, (int, float)) else value
update_expr += f"{safe_key} = :{key}, "
update_expr = update_expr.rstrip(", ")
table.update_item(
Key={"id": product_id},
UpdateExpression=update_expr,
ExpressionAttributeNames=expr_names,
ExpressionAttributeValues=expr_values
)
return response(200, {"message": "Updated"})
def delete_product(product_id):
table.delete_item(Key={"id": product_id})
return response(200, {"message": "Deleted"})
def response(status_code, body):
return {
"statusCode": status_code,
"headers": {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*"
},
"body": json.dumps(body, default=str)
}
This is a complete CRUD API backed by DynamoDB. No server to manage. No database connection pools to configure. No scaling to worry about. API Gateway handles HTTPS termination, request routing, and rate limiting. Lambda handles the business logic. DynamoDB handles the data.
DynamoDB — The Serverless Database
DynamoDB is AWS's serverless NoSQL database. You don't provision servers or manage storage — you create a table, define a key schema, and start reading and writing:
import boto3
dynamodb = boto3.resource("dynamodb")
# Create a table (usually done via Infrastructure as Code, not in Lambda)
table = dynamodb.create_table(
TableName="Orders",
KeySchema=[
{"AttributeName": "userId", "KeyType": "HASH"}, # Partition key
{"AttributeName": "orderId", "KeyType": "RANGE"} # Sort key
],
AttributeDefinitions=[
{"AttributeName": "userId", "AttributeType": "S"},
{"AttributeName": "orderId", "AttributeType": "S"}
],
BillingMode="PAY_PER_REQUEST" # Serverless pricing
)
# Write an item
table.put_item(Item={
"userId": "user-123",
"orderId": "order-456",
"items": [
{"product": "Widget", "quantity": 2, "price": "19.99"}
],
"status": "pending",
"createdAt": "2026-03-26T10:00:00Z"
})
# Query all orders for a user (fast — uses partition key)
result = table.query(
KeyConditionExpression="userId = :uid",
ExpressionAttributeValues={":uid": "user-123"}
)
# Get a specific order (fastest — uses both keys)
result = table.get_item(Key={
"userId": "user-123",
"orderId": "order-456"
})
DynamoDB requires thinking about access patterns upfront. Unlike SQL databases where you can query any column, DynamoDB queries are efficient only on the partition key and sort key. Design your key schema around how you'll query the data, not how the data looks.
Cold Starts — The Serverless Tax
When a Lambda function hasn't been invoked recently, AWS needs to create a new execution environment — download your code, start the runtime, initialize your handler. This is called a cold start, and it adds latency:
- Python: 200-500ms cold start
- Node.js: 150-400ms cold start
- Java: 1-5 seconds cold start (JVM initialization)
- Go/Rust: 50-150ms cold start
Strategies to mitigate cold starts:
# 1. Initialize outside the handler — runs once per cold start
import boto3
import os
# These are initialized once and reused across invocations
dynamodb = boto3.resource("dynamodb")
table = dynamodb.Table(os.environ["TABLE_NAME"])
def lambda_handler(event, context):
# This runs on every invocation — keep it fast
return table.get_item(Key={"id": event["id"]})
# 2. Keep functions warm with scheduled invocations (CloudWatch Events)
# Schedule: rate(5 minutes)
def lambda_handler(event, context):
# Check if this is a warming invocation
if event.get("source") == "aws.events":
return {"statusCode": 200, "body": "Warm"}
# Normal handling
return process_request(event)
# 3. Use Provisioned Concurrency for critical paths
# Configured in Lambda settings, not in code
# Keeps N instances warm at all times (costs more but eliminates cold starts)
For most web APIs, cold starts of 200-500ms are acceptable — the user barely notices. For latency-sensitive applications (real-time APIs, trading systems), provisioned concurrency or a traditional server might be necessary.
Pricing — When Serverless Saves Money
Lambda pricing has three components:
- Requests: $0.20 per million invocations
- Duration: $0.0000166667 per GB-second (memory allocated x execution time)
- Free tier: 1 million requests and 400,000 GB-seconds per month (forever, not just 12 months)
Let's do the math for a typical API:
Scenario: 500,000 requests/month, 200ms average duration, 256MB memory
Requests: 500,000 * $0.0000002 = $0.10
Duration: 500,000 0.2s 0.25GB * $0.0000166667 = $0.42
Total: $0.52/month
Compare to: t3.micro EC2 instance = ~$7.50/month (running 24/7)
For low-to-medium traffic applications, serverless is dramatically cheaper. The free tier alone handles many side projects and small businesses.
But for high-traffic applications:
Scenario: 100 million requests/month, 500ms average, 512MB memory
Requests: 100M * $0.0000002 = $20
Duration: 100M 0.5s 0.5GB * $0.0000166667 = $416.67
Total: $436.67/month
Compare to: Two c5.xlarge instances with a load balancer = ~$250/month
At high sustained traffic, traditional servers win on cost. The crossover point depends on your traffic patterns, but it's typically around 1-10 million requests per month.
SAM and the Serverless Framework
Writing Lambda functions is easy. Managing the infrastructure — API Gateway, DynamoDB tables, IAM roles, S3 buckets — is where the complexity lives. Infrastructure as Code tools help:
AWS SAM (Serverless Application Model):# template.yaml
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Globals:
Function:
Timeout: 30
Runtime: python3.12
MemorySize: 256
Environment:
Variables:
TABLE_NAME: !Ref ProductsTable
Resources:
ProductsApi:
Type: AWS::Serverless::Function
Properties:
Handler: app.lambda_handler
CodeUri: src/
Events:
GetProducts:
Type: Api
Properties:
Path: /products
Method: get
GetProduct:
Type: Api
Properties:
Path: /products/{id}
Method: get
CreateProduct:
Type: Api
Properties:
Path: /products
Method: post
Policies:
- DynamoDBCrudPolicy:
TableName: !Ref ProductsTable
ProductsTable:
Type: AWS::DynamoDB::Table
Properties:
TableName: Products
BillingMode: PAY_PER_REQUEST
AttributeDefinitions:
- AttributeName: id
AttributeType: S
KeySchema:
- AttributeName: id
KeyType: HASH
# Deploy with SAM
sam build
sam deploy --guided
SAM is AWS-native and generates CloudFormation. The Serverless Framework (serverless.com) is cloud-agnostic and has a larger plugin ecosystem. Both solve the same problem: defining your entire application infrastructure in a single file and deploying it with one command.
When Serverless Is Wrong
Serverless is not the right answer for everything. Be skeptical of it when:
Long-running processes. Lambda has a 15-minute execution limit. If your task takes longer — video processing, large data transformations, model training — Lambda won't work. Use ECS, Fargate, or Step Functions for orchestrating longer workflows. Sustained high traffic. If your application consistently handles thousands of requests per second, 24/7, a traditional server is cheaper and has more predictable performance. Serverless shines with variable traffic, not constant traffic. Complex local development. Testing Lambda functions locally is possible (SAM local, LocalStack) but never perfectly replicates the AWS environment. Database connections behave differently. IAM permissions don't exist locally. Event formats need mocking. The development experience is worse than running a local server. Latency-critical applications. Cold starts add 200ms-5s of unpredictable latency. For applications where every millisecond matters, this is unacceptable. Provisioned concurrency helps but adds cost. Stateful applications. Lambda functions are stateless. They can't hold WebSocket connections (you need API Gateway WebSocket API for that), store data in memory between invocations, or maintain long-lived connections to databases (you need RDS Proxy for connection pooling). Vendor lock-in. A Lambda function that uses API Gateway, DynamoDB, SQS, and Step Functions is deeply tied to AWS. Migrating to Azure or GCP means rewriting most of the infrastructure. If multi-cloud matters to you, serverless makes it harder, not easier.The Serverless Mindset
Building well with serverless requires thinking differently:
- Functions should be small and focused. One function per responsibility. Don't build a monolith inside a Lambda.
- Design for failure. Functions can time out, run out of memory, or fail silently. Use dead-letter queues, retry policies, and structured logging.
- Think in events. Instead of "the server processes requests," think "events trigger functions." An upload triggers a processor. A processor triggers a notifier. Each step is independent.
- Embrace managed services. Don't run a database in EC2 and connect from Lambda. Use DynamoDB, Aurora Serverless, or ElastiCache. Let AWS manage the infrastructure so you manage nothing.
Getting Started
The fastest path to deploying a serverless API:
- Install the AWS CLI and SAM CLI
- Run
sam initand select the "Hello World" template - Edit the handler, add your logic
- Run
sam local start-apito test locally - Run
sam deploy --guidedto deploy to AWS
Start small. Deploy a single function that does something useful — processes a webhook, generates a report, sends notifications. Once you understand the deployment cycle and the Lambda execution model, scale up to multi-function applications.
For building the programming fundamentals that make serverless development smoother — working with APIs, handling JSON, async patterns, error handling — practice on CodeUp. Strong coding skills matter more than platform knowledge, because platforms change but programming principles don't.