API Caching Strategies: Complete Guide
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
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ CDN (Edge Cache) โ
โ Static assets, public API data โ
โ (minutes to hours) โ
โโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโ
โ
โโโโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโโโ
โ Application Cache โ
โ (Redis, Memcached) โ
โ (seconds to minutes) โ
โโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโ
โ
โโโโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโโโ
โ Database โ
โ (Primary source) โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
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
});
});
Comments