Serverless Caching Patterns for AWS Lambda
Serverless architecture promises infinite scale and zero server management, but caching in Lambda environments is fundamentally different from traditional applications. Functions are ephemeral, connections are expensive, and cold starts kill performance. This guide covers proven caching patterns specifically designed for AWS Lambda and serverless architectures.
The Serverless Caching Challenge
Traditional caching assumptions break down in serverless:
- Ephemeral execution: Lambda functions may live seconds or hours
- No shared memory: Each function instance has isolated memory
- Connection overhead: Database/cache connections are expensive to establish
- Cold starts: First invocation has higher latency
- Concurrent executions: Thousands of instances may run simultaneously
Pattern 1: Lambda Execution Context Caching
Lambda reuses execution contexts between invocations. Cache data outside the handler function to persist across invocations:
// Module-level cache (persists across invocations)
let configCache = null;
let cacheTimestamp = 0;
const CACHE_TTL = 300000; // 5 minutes
exports.handler = async (event) => {
const now = Date.now();
// Check if cache is valid
if (!configCache || (now - cacheTimestamp) > CACHE_TTL) {
console.log('Cache miss - fetching config');
configCache = await fetchConfig();
cacheTimestamp = now;
} else {
console.log('Cache hit - using cached config');
}
// Use cached config
return processEvent(event, configCache);
};
What to Cache in Execution Context
- Configuration data: Environment settings, feature flags
- Database connections: Reuse connections across invocations
- API clients: SDK clients, HTTP connection pools
- Static data: Lookup tables, validation rules
What NOT to Cache
- User-specific data: Risk of data leakage between requests
- Secrets without rotation: Secrets may update
- Large datasets: Limited to 512MB-10GB memory
Pattern 2: ElastiCache for Distributed Caching
For data shared across Lambda invocations, use ElastiCache (Redis/Memcached) with connection pooling:
const Redis = require('ioredis');
// Create Redis client outside handler (reused across invocations)
let redis = null;
function getRedisClient() {
if (!redis) {
redis = new Redis({
host: process.env.REDIS_HOST,
port: 6379,
// Connection pooling settings
maxRetriesPerRequest: 3,
enableReadyCheck: true,
// Keep connection alive between invocations
keepAlive: 30000,
// Faster connect timeout for Lambda
connectTimeout: 1000
});
}
return redis;
}
exports.handler = async (event) => {
const redis = getRedisClient();
// Try cache first
const userId = event.userId;
const cached = await redis.get(`user:${userId}`);
if (cached) {
return JSON.parse(cached);
}
// Cache miss - fetch and cache
const user = await fetchUser(userId);
await redis.setex(`user:${userId}`, 300, JSON.stringify(user));
return user;
};
ElastiCache Best Practices for Lambda
- Deploy ElastiCache in same VPC as Lambda
- Use connection pooling to handle thousands of concurrent functions
- Set aggressive connection timeouts (1-2 seconds)
- Monitor connection count vs Lambda concurrency
Pattern 3: DynamoDB as Cache Layer
DynamoDB provides serverless caching with automatic scaling and no connection management:
const AWS = require('aws-sdk');
const dynamodb = new AWS.DynamoDB.DocumentClient();
async function getCached(key) {
try {
const result = await dynamodb.get({
TableName: 'CacheTable',
Key: { cacheKey: key }
}).promise();
if (result.Item) {
// Check TTL
if (result.Item.expiresAt > Date.now()) {
return result.Item.value;
}
}
return null;
} catch (err) {
console.error('Cache error:', err);
return null;
}
}
async function setCache(key, value, ttlSeconds) {
const expiresAt = Date.now() + (ttlSeconds * 1000);
await dynamodb.put({
TableName: 'CacheTable',
Item: {
cacheKey: key,
value: value,
expiresAt: expiresAt
}
}).promise();
}
exports.handler = async (event) => {
const cacheKey = `product:${event.productId}`;
// Check DynamoDB cache
let product = await getCached(cacheKey);
if (!product) {
// Cache miss
product = await fetchProduct(event.productId);
await setCache(cacheKey, product, 600);
}
return product;
};
DynamoDB vs ElastiCache
| Feature | DynamoDB | ElastiCache |
|---|---|---|
| Latency | 5-10ms | 1-2ms |
| Scaling | Automatic | Manual cluster sizing |
| Cost | Pay per request | Hourly instance cost |
| Management | Zero | VPC, security groups |
| Best for | Variable traffic | High throughput |
Pattern 4: CloudFront Edge Caching
Cache Lambda@Edge responses at CloudFront edge locations for global low-latency access:
// Lambda@Edge function
exports.handler = async (event) => {
const request = event.Records[0].cf.request;
// Generate cache key from request
const cacheKey = request.uri + '?' + request.querystring;
// CloudFront automatically caches based on response headers
const response = {
status: '200',
statusDescription: 'OK',
headers: {
'content-type': [{ value: 'application/json' }],
// Cache for 1 hour at edge
'cache-control': [{ value: 'public, max-age=3600' }],
// Allow stale content while revalidating
'cache-control': [{ value: 's-maxage=3600, stale-while-revalidate=86400' }]
},
body: JSON.stringify({ data: 'cached response' })
};
return response;
};
When to Use Edge Caching
- API responses with low update frequency
- Static or semi-static content
- Global user base (reduce latency)
- High read-to-write ratio
Pattern 5: API Gateway Caching
Enable API Gateway caching to avoid Lambda invocations for cached responses:
# API Gateway configuration
Resources:
ApiGateway:
Type: AWS::ApiGateway::RestApi
Properties:
Name: CachedAPI
ApiGatewayStage:
Type: AWS::ApiGateway::Stage
Properties:
CacheClusterEnabled: true
CacheClusterSize: '0.5' # GB
MethodSettings:
- ResourcePath: /products/*
HttpMethod: GET
CachingEnabled: true
CacheTtlInSeconds: 300
CacheDataEncrypted: true
Cache Key Customization
// Lambda function sets cache key parameters
exports.handler = async (event) => {
const userId = event.requestContext.authorizer.userId;
const category = event.pathParameters.category;
// API Gateway caches based on path + query params
// Add custom cache key via query string
const products = await getProducts(category, userId);
return {
statusCode: 200,
headers: {
'Cache-Control': 'max-age=300' // 5 minutes
},
body: JSON.stringify(products)
};
};
Pattern 6: Warm-Up Strategies
Prevent cold starts by keeping Lambda functions warm:
// CloudWatch Events rule (every 5 minutes)
{
"schedule": "rate(5 minutes)",
"input": {
"warmup": true
}
}
// Lambda handler
exports.handler = async (event) => {
// Detect warmup invocation
if (event.warmup) {
console.log('Warmup invocation - keeping function warm');
// Initialize connections, load config
await initializeConnections();
return { statusCode: 200, body: 'warmed' };
}
// Regular request processing
return processRequest(event);
};
Pattern 7: Layer-Based Caching
Combine multiple cache layers for optimal performance:
// Layer 1: Execution context cache
// Layer 2: ElastiCache
// Layer 3: Database
let executionCache = new Map();
async function getData(key) {
// Layer 1: Check execution context
if (executionCache.has(key)) {
console.log('L1 cache hit');
return executionCache.get(key);
}
// Layer 2: Check Redis
const redis = getRedisClient();
const cached = await redis.get(key);
if (cached) {
console.log('L2 cache hit');
const data = JSON.parse(cached);
executionCache.set(key, data); // Promote to L1
return data;
}
// Layer 3: Query database
console.log('Cache miss - querying database');
const data = await queryDatabase(key);
// Populate both cache layers
executionCache.set(key, data);
await redis.setex(key, 300, JSON.stringify(data));
return data;
}
Cold Start Optimization
Reduce Package Size
# Use webpack/esbuild to bundle only required code
# Remove development dependencies
# Before: 50MB package = 3-5s cold start
# After: 5MB package = 500ms-1s cold start
npm install --production
zip -r function.zip . -x "*.git*" "*.test.js" "node_modules/aws-sdk/*"
Lazy Load Dependencies
// Bad: Load everything at module level
const AWS = require('aws-sdk');
const heavyLibrary = require('heavy-library');
const anotherLib = require('another-lib');
// Good: Load only what's needed
exports.handler = async (event) => {
if (event.operation === 'process') {
const processor = require('./processor');
return processor.handle(event);
}
// Skip loading processor for other operations
};
Monitoring and Debugging
Track cache effectiveness in Lambda:
const metrics = {
cacheHits: 0,
cacheMisses: 0,
executionContextReused: false
};
exports.handler = async (event) => {
// Detect if execution context was reused
if (metrics.cacheHits > 0 || metrics.cacheMisses > 0) {
metrics.executionContextReused = true;
}
// Track cache metrics
const cached = await getFromCache(event.key);
if (cached) {
metrics.cacheHits++;
} else {
metrics.cacheMisses++;
}
// Log metrics
console.log(JSON.stringify({
cacheHits: metrics.cacheHits,
cacheMisses: metrics.cacheMisses,
hitRate: metrics.cacheHits / (metrics.cacheHits + metrics.cacheMisses),
contextReused: metrics.executionContextReused
}));
};
Cost Optimization
Choose the right caching strategy based on traffic patterns:
- Low traffic (<100 req/min): DynamoDB on-demand caching
- Medium traffic (100-1000 req/min): ElastiCache t3.micro
- High traffic (>1000 req/min): ElastiCache cluster + API Gateway caching
- Global traffic: CloudFront + Lambda@Edge
Conclusion
Caching in serverless environments requires rethinking traditional approaches. Use execution context caching for configuration, ElastiCache or DynamoDB for distributed state, API Gateway/CloudFront for edge caching, and multi-layer strategies for optimal performance. The key is understanding Lambda's ephemeral nature and designing cache strategies that work with it, not against it.
Start with execution context caching for quick wins, add distributed caching as traffic grows, and implement edge caching for global applications.
Serverless Caching Made Simple
Cachee.ai integrates seamlessly with AWS Lambda, handling distributed caching and connection management automatically.
Start Free Trial