Skip to main content

API Caching Strategies: Complete Guide

Created: February 26, 2026 Larry Qu 7 min read

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

Comments

Share this article

Scan to read on mobile

👍 Was this article helpful?