Serverless¶
Serverless isn't "no servers" — it's "you don't manage the servers , someone else does , and you pay only when your code runs" which sounds great until your bill arrives after a DDoS spike or you hit cold start latency at the worst possible moment AWS Lambda is the dominant serverless compute platform. You upload code , AWS runs it on demand , scales it to thousands of concurrent executions , and you pay per millisecond of execution time. No EC2 instances to patch , no SSH keys to manage , no capacity planning
AWS Lambda with Node.js¶
// index.js — Lambda handler
exports.handler = async (event, context) => {
// event contains the trigger data (API Gateway request, SNS message, etc.)
console.log('Event:', JSON.stringify(event, null, 2))
// context has runtime info
console.log('Remaining time:', context.getRemainingTimeInMillis())
try {
const result = await processRequest(event)
return {
statusCode: 200,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
},
body: JSON.stringify(result)
}
} catch (err) {
return {
statusCode: 500,
body: JSON.stringify({ error: err.message })
}
}
}
async function processRequest(event) {
// Your business logic here
const userId = event.pathParameters?.userId
if (!userId) {
throw new Error('Missing userId parameter')
}
// Database connections should be initialized outside the handler
// for connection reuse across invocations with the same container
const user = await db.query('SELECT * FROM users WHERE id = $1', [userId])
return { user: user.rows[0] }
}
Serverless Framework — Infrastructure as Code¶
# Install Serverless Framework
npm install -g serverless
# Create a new service
serverless create --template aws-nodejs --path my-service
# serverless.yml
service: my-api
provider:
name: aws
runtime: nodejs20.x
region: us-east-1
stage: ${opt:stage, 'dev'}
# IAM permissions
iamRoleStatements:
- Effect: Allow
Action:
- dynamodb:Query
- dynamodb:GetItem
- dynamodb:PutItem
Resource: "arn:aws:dynamodb:${aws:region}:*:table/${self:service}-*"
# Environment variables
environment:
NODE_ENV: ${self:provider.stage}
TABLE_NAME: ${self:service}-${self:provider.stage}
LOG_LEVEL: ${env:LOG_LEVEL, 'info'}
functions:
createUser:
handler: handlers/users.createUser
events:
- http:
path: users
method: post
cors: true
# Request validation
request:
schemas:
application/json: ${file(schemas/create-user.json)}
getUser:
handler: handlers/users.getUser
events:
- http:
path: users/{userId}
method: get
cors: true
processPayment:
handler: handlers/payments.processPayment
events:
- sqs:
arn: arn:aws:sqs:us-east-1:123456789012:payment-queue
batchSize: 10
dailyReport:
handler: handlers/reports.dailyReport
events:
- schedule: cron(0 6 * * ? *) # Every day at 6AM UTC
plugins:
- serverless-offline # Local development
- serverless-plugin-optimize # Bundle and minify
- serverless-prune-plugin # Keep only last N versions
package:
individually: true
patterns:
- '!**'
- 'handlers/**'
- 'node_modules/**'
- '!node_modules/aws-sdk/**' # AWS SDK is already in Lambda runtime
Deploy:
# Deploy to dev
serverless deploy
# Deploy to production
serverless deploy --stage production
# Invoke function locally
serverless invoke local --function getUser --path test-event.json
# View logs
serverless logs --function getUser --tail
Cold Starts — The Performance Killer¶
When a Lambda function hasn't been invoked recently , AWS provisions a new container and loads your code. That initial invocation is slower — sometimes 200ms to 2s slower depending on runtime and package size
Invocation 1 (cold start): 1200ms ← Loading code , initializing runtime
Invocation 2 (warm): 45ms ← Container reused
Invocation 3 (warm): 40ms ← Still warm
... (container recycled after 15+ min of inactivity)
Invocation 4 (cold start): 1100ms ← New container
Strategies to reduce cold starts: * Keep packages small — bundle with esbuild or webpack * Use provisioned concurrency (reserve warm containers — costs extra) * Initialize clients outside the handler (reuse DB connections , HTTP clients)
// BAD — client initialized inside handler (on every invocation)
exports.handler = async (event) => {
const client = new DynamoDBClient({}) // Created every time
// ...
}
// GOOD — client initialized outside handler (reused across invocations)
const client = new DynamoDBClient({}) // Created once per container
exports.handler = async (event) => {
// client is already available , no initialization overhead
// ...
}
// Also good — lazy initialization with singleton
let client
function getClient() {
if (!client) {
client = new DynamoDBClient({})
}
return client
}
Function Optimization — Every Millisecond Counts¶
Bundle size matters:
# Use esbuild to bundle Lambda functions
npm install esbuild
# serverless-esbuild plugin
plugins:
- serverless-esbuild
custom:
esbuild:
bundle: true
minify: true
sourcemap: false
target: node20
packager: npm
Database connection management:
const { Pool } = require('pg')
// Connection pool persists across invocations
const pool = new Pool({
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT, 10),
database: process.env.DB_NAME,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
max: 5,
idleTimeoutMillis: 60000,
connectionTimeoutMillis: 5000
})
exports.handler = async (event) => {
const client = await pool.connect()
try {
const result = await client.query('SELECT * FROM users')
return { statusCode: 200, body: JSON.stringify(result.rows) }
} finally {
client.release()
}
}
Timeouts and retries:
// Lambda timeout — max 15 minutes (900 seconds)
// API Gateway timeout — 29 seconds (can't exceed this for HTTP triggers)
// Set function timeout in serverless.yml:
functions:
processPayment:
handler: handlers/payments.processPayment
timeout: 30 # Seconds
memorySize: 512 # MB (more memory = more CPU)
reservedConcurrency: 10 # Max concurrent executions
Event-Driven Patterns¶
Lambda excels at responding to AWS events — S3 uploads , DynamoDB streams , SNS notifications , SQS messages
S3 event (process uploaded file):
functions:
processUpload:
handler: handlers/processUpload.handler
events:
- s3:
bucket: my-uploads-bucket
event: s3:ObjectCreated:*
rules:
- prefix: uploads/
- suffix: .csv
exports.handler = async (event) => {
for (const record of event.Records) {
const bucket = record.s3.bucket.name
const key = decodeURIComponent(record.s3.object.key.replace(/\+/g, ' '))
console.log(`Processing file s3://${bucket}/${key}`)
// Download , process , store results
}
}
DynamoDB Stream (react to data changes):
functions:
auditLog:
handler: handlers/audit.handler
events:
- stream:
type: dynamodb
arn: arn:aws:dynamodb:us-east-1:123456789012:table/users/stream/2024-01-01
batchSize: 100
startingPosition: LATEST
Serverless Security — IAM and Function Permissions¶
The principle of least privilege is non-negotiable. Each function should have the bare minimum permissions to do its job
# BAD — too permissive
provider:
iamRoleStatements:
- Effect: Allow
Action: dynamodb:* # Full DynamoDB access — way too much
Resource: "*"
# GOOD — scoped to specific table and actions
functions:
createUser:
handler: handlers/users.createUser
iamRoleStatements:
- Effect: Allow
Action:
- dynamodb:PutItem
- dynamodb:GetItem
Resource: arn:aws:dynamodb:${aws:region}:*:table/users-table
Function URL (public HTTP endpoint):
functions:
publicApi:
handler: handlers/api.handler
events:
- httpApi:
method: ANY
path: /{proxy+}
auth:
type: aws_iam # Require IAM auth — or use JWT authorizer
Lambda environment variables encryption: Serverless Framework auto-encrypts environment variables using KMS