Caching Fundamentals
What is Caching?
Caching is the practice of storing copies of data in a temporary storage layer (cache) to reduce latency and database load on subsequent reads. When an application needs data, it first checks the cache before falling back to the primary data store.
Why Cache?
Every database query has a cost — CPU cycles, disk I/O, network hops. A typical relational database query might take 5–50ms, while a cache hit returns in under 1ms. At scale, this difference is enormous. A system handling 10,000 requests per second with 80% cache hit rate reduces database load by 8,000 queries per second.
// Simple cache-aside pattern in Node.js
const cache = new Map();
async function getUserProfile(userId) {
// Step 1: Check cache first
const cached = cache.get(\`user:\${userId}\`);
if (cached) {
console.log('Cache HIT');
return cached;
}
// Step 2: Cache miss — query database
console.log('Cache MISS — querying database');
const user = await db.query('SELECT * FROM users WHERE id = $1', [userId]);
// Step 3: Populate cache with TTL of 5 minutes
cache.set(\`user:\${userId}\`, user, { ttl: 300_000 });
return user;
}
Cache Hit Ratio
The cache hit ratio is the most important caching metric. It measures the percentage of requests served from cache:
Hit Ratio = (Cache Hits / (Cache Hits + Cache Misses)) × 100
A healthy cache typically achieves 85–95% hit ratio. Below 70% suggests your caching strategy needs review — either the TTL is too short, the cache size is too small, or the access pattern is not cacheable.
Common Cache Topologies
| Topology | Description | Use Case |
|---|---|---|
| Local (in-process) | Cache lives in application memory | Single-server apps, session data |
| Distributed (Redis/Memcached) | Shared cache across app instances | Microservices, multi-server deployments |
| CDN | Edge cache for static assets and API responses | Global applications, content-heavy sites |
Redis Caching
Redis as a Cache Store
Redis is the most popular in-memory data store for caching. It supports rich data structures (strings, hashes, lists, sets, sorted sets), atomic operations, and persistence options — making it far more capable than simple key-value stores.
Setting Up Redis in Node.js
import Redis from 'ioredis';
// Connection with automatic reconnection and pipeline support
const redis = new Redis({
host: process.env.REDIS_HOST || '127.0.0.1',
port: 6379,
password: process.env.REDIS_PASSWORD,
maxRetriesPerRequest: 3,
retryDelayOnFailover: 100,
enableReadyCheck: true,
lazyConnect: true,
});
// Graceful shutdown
process.on('SIGTERM', async () => {
await redis.quit();
});
// Store with TTL (seconds)
await redis.set('product:42', JSON.stringify(product), 'EX', 3600);
// Retrieve and parse
const cached = await redis.get('product:42');
const product = cached ? JSON.parse(cached) : null;
Eviction Policies
Redis evicts keys when memory is full. The eviction policy determines which keys are removed:
- allkeys-lru: Evict least recently used keys across all keys (recommended for caches)
- volatile-lru: Evict LRU keys that have an expire set
- allkeys-lfu: Evict least frequently used keys (better for skewed access patterns)
- noeviction: Return errors when memory is full (not suitable for caching)
# redis.conf
maxmemory 256mb
maxmemory-policy allkeys-lru
Hash Caching for Object Fields
Instead of storing entire objects, use Redis hashes to cache individual fields and read only what you need:
// Cache user as a hash — update individual fields without rewriting the whole object
await redis.hset('user:42', {
name: 'Jane Doe',
email: 'jane@example.com',
lastLogin: new Date().toISOString(),
});
await redis.expire('user:42', 3600);
// Read only the fields you need
const name = await redis.hget('user:42', 'name');
const email = await redis.hget('user:42', 'email');
HTTP Caching
HTTP Caching with Headers
HTTP caching leverages built-in browser and proxy cache mechanisms via response headers. This is one of the most effective ways to reduce latency for API consumers and web browsers.
Cache-Control Header
The Cache-Control header tells browsers and proxies how to cache the response:
// Express.js example
app.get('/api/products/:id', async (req, res) => {
const product = await Product.findById(req.params.id);
// Public: CDNs can cache. Private: only browser caches.
// max-age=300: fresh for 5 minutes (in seconds)
// stale-while-revalidate=60: serve stale for 60s while fetching fresh copy
res.set({
'Cache-Control': 'public, max-age=300, stale-while-revalidate=60',
'Content-Type': 'application/json',
});
res.json(product);
});
// Never cache (authentication-required endpoints)
app.get('/api/me', authenticate, async (req, res) => {
res.set('Cache-Control', 'private, no-store, no-cache');
res.json(req.user);
});
ETags and Conditional Requests
ETags enable conditional requests — the client sends back the ETag it received, and the server responds with 304 Not Modified if nothing changed, avoiding unnecessary data transfer:
import crypto from 'crypto';
app.get('/api/reports/:id', async (req, res) => {
const report = await Report.findById(req.params.id);
const etag = crypto
.createHash('md5')
.update(JSON.stringify(report) + report.updatedAt)
.digest('hex');
// If client already has this version, return 304
if (req.headers['if-none-match'] === etag) {
return res.status(304).end();
}
res.set({
'ETag': `"\${etag}"`,
'Cache-Control': 'private, max-age=60',
});
res.json(report);
});
Cache Invalidation at the HTTP Layer
// After updating a product, invalidate related cache keys
app.put('/api/products/:id', authenticate, async (req, res) => {
const product = await Product.findByIdAndUpdate(req.params.id, req.body, { new: true });
// Purge CDN cache for this product
await cdn.purge(\`/api/products/\${req.params.id}\`);
res.json(product);
});
Cache-Aside, Write-Through, and Invalidation
Cache Design Patterns
Cache-Aside (Lazy Loading)
The application manages the cache explicitly. Reads check cache first; writes invalidate the cache. This is the most common pattern for read-heavy workloads.
async function getProduct(productId) {
const cacheKey = \`product:\${productId}\`;
// 1. Check cache
const cached = await redis.get(cacheKey);
if (cached) return JSON.parse(cached);
// 2. Fetch from database
const product = await db.product.findById(productId);
if (!product) return null;
// 3. Populate cache with TTL
await redis.set(cacheKey, JSON.stringify(product), 'EX', 600);
return product;
}
async function updateProduct(productId, updates) {
// 1. Update database
const product = await db.product.findByIdAndUpdate(productId, updates, { new: true });
// 2. Invalidate cache (delete, don't update — next read will repopulate)
await redis.del(\`product:\${productId}\`);
// 3. Also invalidate list caches that might contain this product
const keys = await redis.keys('products:list:*');
if (keys.length > 0) await redis.del(...keys);
return product;
}
Pros: Simple, resilient (cache failure just means slower reads), only caches what's actually read.
Cons: Cache miss penalty (3 round trips: cache check, DB query, cache write), data can be stale briefly.
Write-Through
The application writes to the cache and database simultaneously. The cache is always consistent with the database.
async function createOrder(orderData) {
// Write to database
const order = await db.order.create(orderData);
// Write to cache simultaneously
await redis.set(\`order:\${order.id}\`, JSON.stringify(order), 'EX', 3600);
// Update user's order list cache
await redis.lpush(\`orders:user:\${order.userId}\`, order.id);
await redis.expire(\`orders:user:\${order.userId}\`, 3600);
return order;
}
Pros: No cache miss on reads, strong consistency.
Cons: Higher write latency (two writes per operation), may cache data that is rarely read.
Quiz
1. What is the primary advantage of the cache-aside pattern over write-through caching?
2. How does the `stale-while-revalidate` Cache-Control directive work?
3. What problem does the thundering herd effect cause in caching, and how can it be mitigated?
Flashcards
Question
What is the cache-aside (lazy loading) pattern?
Click to reveal answer
Answer
The application explicitly manages the cache: reads check the cache first, and on a miss, fetch from the database and populate the cache. Writes invalidate the relevant cache keys so the next read repopulates it. This is the most common pattern for read-heavy workloads because it only caches data that is actually requested.
Question
What Redis eviction policy is best for a general-purpose application cache?
Click to reveal answer
Answer
allkeys-lru (Least Recently Used) is the recommended default for most caches. It evicts the least recently accessed keys across the entire keyspace when memory is full. For workloads with highly skewed access patterns (a few keys accessed very frequently), allkeys-lfu (Least Frequently Used) may be better.
Question
What is the difference between Cache-Control: private and Cache-Control: public?
Click to reveal answer
Answer
private means only the end-user's browser can cache the response — shared caches like CDNs and proxies must not cache it. Use this for personalized data (user profiles, account pages). public means any cache (browser, CDN, proxy) can cache the response. Use this for static or non-personalized data (product pages, public API responses).
Revision Notes
Key Takeaways
- 1. Cache-aside is the most common pattern: read from cache first, miss → fetch from DB → populate cache. Writes invalidate the cache.
- 2. Redis is the go-to for distributed caching. Use allkeys-lru eviction, set sensible TTLs, and leverage hashes for partial object caching.
- 3. HTTP caching via Cache-Control and ETags reduces latency at the CDN and browser layer without any application code changes.
- 4. Cache invalidation is the hardest problem: use event-based invalidation with TTL as a safety net. Watch out for the thundering herd effect on key expiry.
Interview Tips
- • When asked to design a caching layer, start by clarifying read/write ratio, consistency requirements, and data volatility — these determine the pattern.
- • Be ready to discuss cache invalidation strategies and trade-offs: TTL-based (simple, eventual consistency) vs event-driven (complex, stronger consistency) vs hybrid.
- • Know the thundering herd problem and mitigation: distributed locks, early expiration, and probabilistic recomputation.
- • Explain ETags and conditional requests — interviewers often test whether you understand HTTP-level caching beyond just Redis.
- • Mention monitoring: always track cache hit ratio, eviction rate, and memory usage in production.
Cheat Sheet
Cache Patterns: Cache-Aside (read: cache→DB→cache; write: DB→invalidate), Write-Through (write: cache+DB simultaneously), Write-Behind (write: cache→async DB). Redis: use allkeys-lru, set TTLs, leverage hashes for partial reads. HTTP: Cache-Control (max-age, stale-while-revalidate, private/public), ETags for conditional requests (304 Not Modified), Vary header for personalization. Invalidation: event-based + TTL safety net. Watch: thundering herd (locks, early expiry), stampede (probabilistic recomputation), hot keys (replication, local cache).