AWS Lambda and Serverless: Beginner's Guide 2026
Serverless computing allows you to run code without provisioning or managing servers. AWS Lambda is the most widely adopted serverless compute platform, powering millions of applications from simple API endpoints to complex event-driven architectures. You pay only for the compute time you consume, making it ideal for variable workloads, microservices, and event-driven processing. This guide covers everything you need to start building serverless applications with AWS Lambda in 2026.
What is Serverless?
Serverless does not mean there are no servers. It means the cloud provider manages the servers for you. You write functions, define triggers, and the platform handles provisioning, scaling, patching, and availability. Key characteristics of serverless:
- No server management - No EC2 instances, no OS updates, no capacity planning
- Auto-scaling - Scales from zero to thousands of concurrent executions automatically
- Pay-per-use - Billed only for actual execution time, down to the millisecond
- Event-driven - Functions are triggered by events: HTTP requests, file uploads, database changes, queue messages, and schedules
- Stateless - Each function invocation is independent; state must be stored externally
Your First Lambda Function
A Lambda function is a single-purpose piece of code that runs in response to an event. Here is a simple Lambda function in Node.js that handles an API Gateway request:
// handler.ts - A basic Lambda function
import { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";
export const handler = async (
event: APIGatewayProxyEvent
): Promise<APIGatewayProxyResult> => {
try {
const name = event.queryStringParameters?.name || "World";
return {
statusCode: 200,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
},
body: JSON.stringify({
message: `Hello, ${name}!`,
timestamp: new Date().toISOString(),
requestId: event.requestContext.requestId,
}),
};
} catch (error) {
console.error("Error:", error);
return {
statusCode: 500,
body: JSON.stringify({ error: "Internal server error" }),
};
}
};Setting Up with the Serverless Framework
The Serverless Framework is the most popular tool for developing and deploying Lambda functions. It handles packaging, deployment, IAM permissions, and infrastructure as code.
# Install the Serverless Framework
npm install -g serverless
# Create a new project
serverless create --template aws-nodejs-typescript --path my-service
cd my-service
npm install
# serverless.yml - Service configuration
service: my-api
frameworkVersion: '4'
provider:
name: aws
runtime: nodejs20.x
region: us-east-1
stage: ${opt:stage, 'dev'}
memorySize: 256
timeout: 30
environment:
TABLE_NAME: ${self:service}-${self:provider.stage}-items
STAGE: ${self:provider.stage}
iam:
role:
statements:
- Effect: Allow
Action:
- dynamodb:GetItem
- dynamodb:PutItem
- dynamodb:UpdateItem
- dynamodb:DeleteItem
- dynamodb:Query
- dynamodb:Scan
Resource:
- !GetAtt ItemsTable.Arn
- !Join ['/', [!GetAtt ItemsTable.Arn, 'index/*']]
functions:
getItems:
handler: src/handlers/items.getAll
events:
- httpApi:
path: /items
method: GET
getItem:
handler: src/handlers/items.getOne
events:
- httpApi:
path: /items/{id}
method: GET
createItem:
handler: src/handlers/items.create
events:
- httpApi:
path: /items
method: POST
deleteItem:
handler: src/handlers/items.remove
events:
- httpApi:
path: /items/{id}
method: DELETE
processQueue:
handler: src/handlers/queue.process
events:
- sqs:
arn: !GetAtt ProcessingQueue.Arn
batchSize: 10
scheduledTask:
handler: src/handlers/cron.cleanup
events:
- schedule: rate(1 hour)
resources:
Resources:
ItemsTable:
Type: AWS::DynamoDB::Table
Properties:
TableName: ${self:provider.environment.TABLE_NAME}
BillingMode: PAY_PER_REQUEST
AttributeDefinitions:
- AttributeName: id
AttributeType: S
KeySchema:
- AttributeName: id
KeyType: HASH
ProcessingQueue:
Type: AWS::SQS::Queue
Properties:
QueueName: ${self:service}-${self:provider.stage}-processing# Deploy to AWS
serverless deploy --stage dev
# Deploy a single function (faster for development)
serverless deploy function -f getItems
# Invoke a function locally
serverless invoke local -f getItems
# View logs
serverless logs -f getItems --tail
# Remove the entire stack
serverless remove --stage devBuilding a CRUD API
Here is a complete CRUD handler for a DynamoDB-backed API using Lambda:
// src/handlers/items.ts
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import {
DynamoDBDocumentClient,
GetCommand,
PutCommand,
DeleteCommand,
ScanCommand,
} from "@aws-sdk/lib-dynamodb";
import { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";
import { randomUUID } from "crypto";
const client = new DynamoDBClient({});
const docClient = DynamoDBDocumentClient.from(client);
const TABLE_NAME = process.env.TABLE_NAME!;
function response(statusCode: number, body: unknown): APIGatewayProxyResult {
return {
statusCode,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
},
body: JSON.stringify(body),
};
}
// GET /items
export const getAll = async (): Promise<APIGatewayProxyResult> => {
try {
const result = await docClient.send(
new ScanCommand({ TableName: TABLE_NAME })
);
return response(200, { items: result.Items, count: result.Count });
} catch (error) {
console.error("Error fetching items:", error);
return response(500, { error: "Failed to fetch items" });
}
};
// GET /items/:id
export const getOne = async (
event: APIGatewayProxyEvent
): Promise<APIGatewayProxyResult> => {
const id = event.pathParameters?.id;
if (!id) return response(400, { error: "Missing item ID" });
try {
const result = await docClient.send(
new GetCommand({ TableName: TABLE_NAME, Key: { id } })
);
if (!result.Item) return response(404, { error: "Item not found" });
return response(200, result.Item);
} catch (error) {
console.error("Error fetching item:", error);
return response(500, { error: "Failed to fetch item" });
}
};
// POST /items
export const create = async (
event: APIGatewayProxyEvent
): Promise<APIGatewayProxyResult> => {
if (!event.body) return response(400, { error: "Missing request body" });
try {
const data = JSON.parse(event.body);
const item = {
id: randomUUID(),
...data,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
await docClient.send(
new PutCommand({ TableName: TABLE_NAME, Item: item })
);
return response(201, item);
} catch (error) {
console.error("Error creating item:", error);
return response(500, { error: "Failed to create item" });
}
};
// DELETE /items/:id
export const remove = async (
event: APIGatewayProxyEvent
): Promise<APIGatewayProxyResult> => {
const id = event.pathParameters?.id;
if (!id) return response(400, { error: "Missing item ID" });
try {
await docClient.send(
new DeleteCommand({ TableName: TABLE_NAME, Key: { id } })
);
return response(200, { message: "Item deleted", id });
} catch (error) {
console.error("Error deleting item:", error);
return response(500, { error: "Failed to delete item" });
}
};Event Sources and Triggers
Lambda functions can be triggered by over 200 AWS services. Here are the most common event sources and their typical use cases:
| Event Source | Use Case | Invocation Type |
|---|---|---|
| API Gateway | REST/HTTP APIs, webhooks | Synchronous |
| S3 | File processing, image resizing, ETL | Asynchronous |
| SQS | Queue processing, decoupled workflows | Polling |
| DynamoDB Streams | Change data capture, real-time sync | Polling |
| EventBridge | Event routing, scheduled tasks, cross-service events | Asynchronous |
| SNS | Fan-out notifications, pub/sub messaging | Asynchronous |
| CloudWatch Events | Cron jobs, scheduled maintenance | Asynchronous |
| Cognito | User signup/login triggers, custom auth flows | Synchronous |
| Kinesis | Real-time data streaming, log processing | Polling |
| ALB | Application Load Balancer integration | Synchronous |
S3 Event Processing Example
// Process uploaded images
import { S3Event } from "aws-lambda";
import { S3Client, GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3";
import sharp from "sharp";
const s3 = new S3Client({});
export const handler = async (event: S3Event) => {
for (const record of event.Records) {
const bucket = record.s3.bucket.name;
const key = decodeURIComponent(record.s3.object.key);
// Skip if already a thumbnail
if (key.startsWith("thumbnails/")) continue;
console.log(`Processing: ${bucket}/${key}`);
// Download original image
const original = await s3.send(
new GetObjectCommand({ Bucket: bucket, Key: key })
);
const imageBuffer = Buffer.from(
await original.Body!.transformToByteArray()
);
// Generate thumbnail
const thumbnail = await sharp(imageBuffer)
.resize(200, 200, { fit: "cover" })
.jpeg({ quality: 80 })
.toBuffer();
// Upload thumbnail
const thumbnailKey = `thumbnails/${key.replace(/\.[^.]+$/, ".jpg")}`;
await s3.send(
new PutObjectCommand({
Bucket: bucket,
Key: thumbnailKey,
Body: thumbnail,
ContentType: "image/jpeg",
})
);
console.log(`Thumbnail created: ${thumbnailKey}`);
}
};Cold Starts and Performance
A cold start occurs when Lambda creates a new execution environment for your function. This adds latency (typically 100ms to several seconds) to the first invocation. Understanding and mitigating cold starts is crucial for performance-sensitive applications.
- Runtime choice matters - Node.js and Python have the fastest cold starts (100-300ms). Java and .NET are slower (1-5 seconds) without SnapStart
- Memory affects CPU - Lambda allocates CPU proportional to memory. 1769MB equals one full vCPU. More memory means faster initialization
- Keep bundles small - Smaller deployment packages initialize faster. Use tree-shaking and avoid importing entire SDKs
- Provisioned concurrency - Pre-warms execution environments to eliminate cold starts for critical paths
- SnapStart - Available for Java, takes a snapshot of initialized memory and restores it for near-instant starts
// Optimize cold starts: Initialize clients outside the handler
// These run once per cold start, not on every invocation
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb";
// Module-level initialization (runs once per cold start)
const client = new DynamoDBClient({});
const docClient = DynamoDBDocumentClient.from(client);
// Handler runs on every invocation
export const handler = async (event: any) => {
// docClient is reused across invocations (warm starts)
// This avoids re-creating the connection on every call
const result = await docClient.send(/* ... */);
return result;
};
// Bundle optimization with esbuild (serverless.yml)
// plugins:
// - serverless-esbuild
// custom:
// esbuild:
// bundle: true
// minify: true
// sourcemap: true
// exclude:
// - "@aws-sdk/*" # Available in Lambda runtime
// target: node20Error Handling and Retry Logic
// Structured error handling for Lambda
class AppError extends Error {
constructor(
message: string,
public statusCode: number,
public code: string
) {
super(message);
this.name = "AppError";
}
}
export const handler = async (event: APIGatewayProxyEvent) => {
try {
// Validate input
if (!event.body) {
throw new AppError("Request body is required", 400, "MISSING_BODY");
}
const data = JSON.parse(event.body);
if (!data.email) {
throw new AppError("Email is required", 400, "MISSING_EMAIL");
}
// Business logic
const result = await processRequest(data);
return {
statusCode: 200,
body: JSON.stringify(result),
};
} catch (error) {
if (error instanceof AppError) {
return {
statusCode: error.statusCode,
body: JSON.stringify({
error: error.code,
message: error.message,
}),
};
}
// Unexpected errors - log full details, return generic message
console.error("Unhandled error:", JSON.stringify(error, null, 2));
return {
statusCode: 500,
body: JSON.stringify({
error: "INTERNAL_ERROR",
message: "An unexpected error occurred",
}),
};
}
};
// SQS handler with partial batch failure reporting
import { SQSBatchResponse, SQSEvent } from "aws-lambda";
export const sqsHandler = async (event: SQSEvent): Promise<SQSBatchResponse> => {
const failures: string[] = [];
for (const record of event.Records) {
try {
const body = JSON.parse(record.body);
await processMessage(body);
} catch (error) {
console.error(`Failed to process ${record.messageId}:`, error);
failures.push(record.messageId);
}
}
return {
batchItemFailures: failures.map((id) => ({
itemIdentifier: id,
})),
};
};Monitoring and Observability
// Structured logging for CloudWatch
function log(level: string, message: string, data?: Record<string, unknown>) {
console.log(JSON.stringify({
level,
message,
timestamp: new Date().toISOString(),
...data,
}));
}
// Usage in handler
export const handler = async (event: APIGatewayProxyEvent) => {
const requestId = event.requestContext.requestId;
log("info", "Request received", {
requestId,
method: event.httpMethod,
path: event.path,
});
const startTime = Date.now();
const result = await processRequest(event);
const duration = Date.now() - startTime;
log("info", "Request completed", {
requestId,
duration,
statusCode: result.statusCode,
});
return result;
};
// Custom CloudWatch metrics
import { CloudWatchClient, PutMetricDataCommand } from "@aws-sdk/client-cloudwatch";
const cw = new CloudWatchClient({});
async function publishMetric(name: string, value: number, unit = "Count") {
await cw.send(new PutMetricDataCommand({
Namespace: "MyApp",
MetricData: [{
MetricName: name,
Value: value,
Unit: unit,
Timestamp: new Date(),
}],
}));
}Lambda Pricing (2026)
| Component | Price | Free Tier |
|---|---|---|
| Requests | $0.20 per 1M requests | 1M requests/month |
| Duration (128MB) | $0.0000000021 per ms | 400,000 GB-seconds/month |
| Duration (1GB) | $0.0000000167 per ms | Included in GB-seconds |
| Provisioned Concurrency | $0.0000041667 per GB-second | None |
For context, a function with 256MB memory running 100ms per invocation, called 1 million times per month, costs approximately $2.50 total. The generous free tier covers most development and low-traffic production workloads.
Best Practices
- Keep functions focused - Each function should do one thing well. Follow the single responsibility principle
- Initialize outside the handler - Put database connections, SDK clients, and config loading at module level to reuse across invocations
- Set appropriate timeouts - Default is 3 seconds, maximum is 15 minutes. Set based on expected execution time plus buffer
- Use environment variables - Never hardcode configuration, credentials, or stage-specific values
- Enable X-Ray tracing - AWS X-Ray provides distributed tracing across Lambda, API Gateway, DynamoDB, and other services
- Use Dead Letter Queues - Configure DLQ (SQS or SNS) for async invocations to capture failed events
- Minimize deployment package size - Use bundlers (esbuild, webpack), exclude dev dependencies, and leverage Lambda layers for shared code
- Use ARM64 architecture - Graviton2 (arm64) functions are 20% cheaper and often faster than x86_64
- Implement idempotency - Lambda can retry invocations, so ensure your functions produce the same result when called multiple times with the same input
- Use Lambda Powertools - AWS Lambda Powertools provides logging, tracing, metrics, and middleware for TypeScript and Python
Frequently Asked Questions
When should I NOT use Lambda?
Lambda is not ideal for long-running processes (over 15 minutes), workloads requiring persistent connections (WebSockets, though API Gateway WebSocket API can help), applications with consistent high traffic (a dedicated server may be cheaper), or workloads with very large memory requirements (maximum is 10GB). For these cases, consider ECS Fargate, EC2, or App Runner.
How do I handle database connections in Lambda?
Use RDS Proxy or connection pooling services to manage database connections. Lambda creates many concurrent execution environments, each with its own connection, which can exhaust database connection limits. RDS Proxy pools and shares connections across Lambda invocations. For DynamoDB, this is not an issue as it uses HTTP connections.
Can I use Lambda for a full web application?
Yes. Frameworks like SST, Serverless Stack, and AWS SAM simplify building full-stack serverless applications with Lambda, API Gateway, DynamoDB, S3, and CloudFront. Next.js can also be deployed on Lambda using OpenNext. However, evaluate whether the complexity is worth it compared to simpler deployment options like Vercel or AWS App Runner.
What is the difference between Lambda and Fargate?
Lambda runs individual functions triggered by events with automatic scaling and sub-second billing. Fargate runs Docker containers as long-running services with per-second billing. Lambda is better for event-driven workloads and APIs with variable traffic. Fargate is better for applications that need persistent connections, long processing times, or consistent high throughput.
How do I test Lambda functions locally?
Use serverless invoke local for quick testing, SAM CLI (sam local invoke) for Docker-based local execution, or write unit tests with Jest that mock AWS SDK calls. For integration testing, deploy to a dedicated dev/staging environment. Tools like LocalStack can emulate AWS services locally for comprehensive testing.