Caching is essential for building high-performance APIs. This guide covers everything from HTTP caching headers to distributed caching with Redis and CDN integration.
Why API Caching Matters
- Reduces Latency: Serve cached responses instantly
- Decreases Server Load: Fewer requests hit your backend
- Improves Scalability: Handle more traffic with same resources
- Cost Reduction: Lower cloud infrastructure costs
HTTP Caching Headers
Cache-Control
# No caching
Cache-Control: no-store
Cache-Control: no-cache, must-revalidate
# Cache with expiration
Cache-Control: max-age=3600
Cache-Control: max-age=3600, public
# Shared caching (proxies)
Cache-Control: s-maxage=7200
Cache-Control: s-maxage=3600, proxy-revalidate
# Validation
Cache-Control: must-revalidate
Cache-Control: stale-while-revalidate=600
Cache-Control: stale-if-error=86400
Implementation
// Express.js - Set cache headers
app.get('/api/users', (req, res) => {
// Cache for 5 minutes, shared for 10 minutes
res.set('Cache-Control', 'public, max-age=300, s-maxage=600');
// Or disable caching
res.set('Cache-Control', 'no-store, no-cache, must-revalidate');
res.json(users);
});
// Cache for different response types
app.get('/api/users/:id', (req, res) => {
if (req.headers['if-none-match']) {
// Client has cached version, check ETag
const etag = generateETag(user);
if (req.headers['if-none-match'] === etag) {
return res.status(304).end(); // Not Modified
}
}
res.set('ETag', generateETag(user));
res.set('Cache-Control', 'private, max-age=300');
res.json(user);
});
ETag (Entity Tags)
import hashlib
import json
from functools import wraps
def generate_etag(data):
"""Generate ETag from data"""
content = json.dumps(data, sort_keys=True)
return f'"{hashlib.md5(content.encode()).hexdigest()}"'
def etag_middleware(app):
@app.route('/api/resource')
def get_resource():
data = fetch_data()
etag = generate_etag(data)
# Check If-None-Match header
if request.headers.get('If-None-Match') == etag:
return '', 304
response = jsonify(data)
response.headers['ETag'] = etag
response.headers['Cache-Control'] = 'max-age=0, must-revalidate'
return response
return app
Last-Modified & If-Modified-Since
// Express.js with Last-Modified
app.get('/api/users', (req, res) => {
const users = fetchUsers();
const lastModified = new Date(Math.max(...users.map(u => new Date(u.updatedAt))));
// Check if client has newer version
const ifModifiedSince = req.headers['if-modified-since'];
if (ifModifiedSince) {
const clientDate = new Date(ifModifiedSince);
if (clientDate >= lastModified) {
return res.status(304).end();
}
}
res.set('Last-Modified', lastModified.toUTCString());
res.set('Cache-Control', 'public, max-age=300');
res.json(users);
});
Vary Header
# Cache different versions based on headers
Vary: Accept-Encoding
Vary: Accept-Language
Vary: User-Agent
Vary: Cookie
// Implementation
app.get('/api/data', (req, res) => {
// Different users see different data
res.set('Vary', 'Cookie, Authorization');
// Or vary by client
res.set('Vary', 'Accept, Accept-Encoding');
res.json(data);
});
Application-Level Caching
In-Memory Caching
// Node.js with node-cache
const NodeCache = require('node-cache');
const cache = new NodeCache({
stdTTL: 300, // 5 minutes default
checkperiod: 60, // Check every 60 seconds
useClones: false // Use references for performance
});
// Get or fetch
async function getUsers() {
const cacheKey = 'users:list';
let users = cache.get(cacheKey);
if (!users) {
users = await db.users.findAll();
cache.set(cacheKey, users, 300);
}
return users;
}
// With async/await wrapper
const getOrSet = async (key, fn, ttl = 300) => {
const cached = cache.get(key);
if (cached) return cached;
const result = await fn();
cache.set(key, result, ttl);
return result;
};
// Usage
app.get('/api/users', async (req, res) => {
const users = await getOrSet('users:list', () => db.users.findAll());
res.json(users);
});
Redis Caching
const redis = require('redis');
const client = redis.createClient();
client.on('error', (err) => console.error('Redis error:', err));
// Basic operations
async function cacheGet(key) {
const data = await client.get(key);
return data ? JSON.parse(data) : null;
}
async function cacheSet(key, value, ttlSeconds = 300) {
await client.setex(key, ttlSeconds, JSON.stringify(value));
}
async function cacheDelete(key) {
await client.del(key);
}
// Pattern: Cache-aside (Lazy Loading)
async function getUser(userId) {
const cacheKey = `user:${userId}`;
// Check cache first
let user = await cacheGet(cacheKey);
if (user) {
return user;
}
// Fetch from database
user = await db.users.findById(userId);
// Store in cache
if (user) {
await cacheSet(cacheKey, user, 600); // 10 minutes
}
return user;
}
// Pattern: Write-through
async function updateUser(userId, data) {
// Update database first
const user = await db.users.update(userId, data);
// Then update cache
await cacheSet(`user:${userId}`, user, 600);
return user;
}
Cache Invalidation Strategies
Types of Invalidation
// 1. Time-based (TTL)
await cacheSet('users:list', users, 300); // 5 minutes
// 2. Event-based (On write)
async function onUserUpdate(userId) {
await cacheDelete(`user:${userId}`);
await cacheDelete('users:list');
await cacheDelete('users:recent');
}
// 3. Tag-based
async function invalidateByTag(tag) {
const keys = await client.keys(`*:${tag}:*`);
if (keys.length) {
await client.del(...keys);
}
}
// Tag example
await cacheSet('user:123:profile', user, 300, ['user:123', 'profiles']);
await cacheSet('users:list', users, 300, ['users', 'lists']);
Advanced Patterns
# Python with cache patterns
from functools import wraps
import hashlib
import json
class Cache:
def __init__(self, redis_client):
self.redis = redis_client
def invalidate_pattern(self, pattern):
keys = self.redis.keys(pattern)
if keys:
self.redis.delete(*keys)
def user_updated(self, user_id):
"""Invalidate all caches related to a user"""
self.invalidate_pattern(f"user:{user_id}:*")
self.invalidate_pattern("users:list:*")
self.invalidate_pattern("users:count")
cache = Cache(redis_client)
# Use in repository
class UserRepository:
def update(self, user_id, data):
user = self.db.update(user_id, data)
cache.user_updated(user_id) # Invalidate caches
return user
CDN Caching
Setting Up CDN Headers
// CloudFront-compatible headers
app.get('/api/assets/*', (req, res) => {
// Cache in CDN for 1 hour, in browser for 5 minutes
res.set('Cache-Control', 'public, max-age=300, s-maxage=3600');
// Versioned assets can be cached long-term
res.set('Cache-Control', 'public, max-age=31536000, immutable');
res.sendFile(req.params.path);
});
// API responses - shorter cache
app.get('/api/config', (req, res) => {
res.set('Cache-Control', 'public, max-age=60, s-maxage=300');
res.json(config);
});
Cache-Key Strategies
# Default: URL only
# GET /api/users → cached
# With query params
# GET /api/users?page=2 → separate cache
# With vary
# GET /api/data with Accept: application/json → one version
# GET /api/data with Accept: application/xml → different version
CDN Integration Example
// Fastly
app.use((req, res, next) => {
res.set('Surrogate-Control', 'max-age=3600');
res.set('Cache-Tag', 'users,api,authenticated');
next();
});
// CloudFront
app.use((req, res, next) => {
res.set('CloudFront-Is-Desktop-Viewer', 'true');
res.set('CloudFront-Is-Mobile-Viewer', 'false');
res.set('CloudFront-Forwarded-Proto', 'https');
next();
});
API-Specific Caching Strategies
Response Caching
// Cache expensive API responses
const cache = new Map();
function cacheResponse(key, data, ttl) {
cache.set(key, {
data,
expires: Date.now() + ttl
});
}
function getCachedResponse(key) {
const cached = cache.get(key);
if (cached && cached.expires > Date.now()) {
return cached.data;
}
cache.delete(key);
return null;
}
// Middleware
app.get('/api/search', (req, res) => {
const cacheKey = `search:${JSON.stringify(req.query)}`;
const cached = getCachedResponse(cacheKey);
if (cached) {
return res.set('X-Cache', 'HIT').json(cached);
}
const results = performSearch(req.query);
cacheResponse(cacheKey, results, 60000); // 1 minute
res.set('X-Cache', 'MISS').json(results);
});
Query Result Caching
// SQL query caching
const queryCache = new Map();
async function executeQuery(sql, params) {
const cacheKey = JSON.stringify({ sql, params });
// Check cache
const cached = queryCache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < 60000) {
return cached.result;
}
// Execute query
const result = await db.query(sql, params);
// Cache result
queryCache.set(cacheKey, {
result,
timestamp: Date.now()
});
return result;
}
Caching Best Practices
Cache Hierarchy
flowchart BT
DB[(Database\nPrimary source)]
AC[Application Cache\nRedis / Memcached\nseconds to minutes]
CDN[CDN Edge Cache\nStatic assets, public API\nminutes to hours]
DB --> AC --> CDN
Decision Matrix
| Data Type | Cache Location | TTL | Invalidation |
|---|---|---|---|
| Static assets | CDN | Long (1 year) | Version URL |
| User public profile | Redis + CDN | Medium (1 hour) | On update |
| User private data | Browser | Short (5 min) | On update |
| Search results | Redis | Short (1 min) | Time-based |
| Session data | Redis | Session | Logout |
| API rate limits | Redis | Real-time | Auto |
Common Mistakes
// Bad: No cache key
cache.set('users', users);
// Good: Specific cache keys
cache.set('users:list:page=1:sort=name', users);
cache.set(`user:${userId}:profile`, user);
// Bad: No TTL
cache.set('data', data);
// Good: Always set TTL
cache.set('data', data, 300);
// Bad: Caching user-specific data in shared cache
cache.set('users', users); // All users see same cache!
// Good: User-specific keys
cache.set(`users:${userId}`, userData);
Performance Monitoring
// Track cache hit rate
const cacheStats = { hits: 0, misses: 0 };
function trackCache(operation, key) {
if (operation === 'hit') cacheStats.hits++;
if (operation === 'miss') cacheStats.misses++;
const total = cacheStats.hits + cacheStats.misses;
const hitRate = total > 0 ? (cacheStats.hits / total * 100).toFixed(2) : 0;
console.log(`Cache ${operation}: ${key}`);
console.log(`Hit rate: ${hitRate}%`);
}
// Expose metrics
app.get('/metrics/cache', (req, res) => {
const total = cacheStats.hits + cacheStats.misses;
res.json({
hits: cacheStats.hits,
misses: cacheStats.misses,
hitRate: total > 0 ? (cacheStats.hits / total) : 0
});
});
Conclusion
Effective API caching requires a layered approach: HTTP headers for browser and CDN caching, application-level caches for computed results and database queries, and CDN edge caches for static and public content. Choose your cache location and TTL based on data freshness requirements—static assets can live in CDN for months, while user-specific data needs short TTLs and event-driven invalidation.
Monitor cache hit rates and invalidate aggressively on writes. When in doubt, start with conservative TTLs and increase as you measure real-world access patterns.
For broader API design patterns that complement caching strategies, see the REST API Design Best Practices guide. For managing API traffic alongside caching, see the API Rate Limiting Strategies guide. For implementing cache-related routing at the edge, see the API Gateway Patterns guide.
Resources
- MDN HTTP Caching - Full caching spec reference
- HTTP Cache-Control - Header directive documentation
- Redis Documentation - Distributed cache setup and patterns
- Cloudflare Cache Docs - CDN cache configuration
Comments