Cache‑aside
Backend Fundamentals · Caching · 8 min read
1. What problem does this solve?
| Problem | Why it matters | Consequence if ignored | Real‑world example |
|---|---|---|---|
| Database overload | A single DB instance can become a bottleneck when many read requests hit it. | Slow responses, timeouts, and eventual service degradation. | A social‑media feed that pulls millions of posts per minute. |
| High latency | Network round‑trips to a remote DB add latency. | Poor user experience, especially on mobile or edge devices. | A mobile app that loads product details from a distant Postgres cluster. |
| Cost | Scaling DBs (read replicas, sharding) is expensive. | Higher infrastructure spend. | E‑commerce sites that need to support flash sales. |
| Availability | If the DB is down, the whole service can fail. | Downtime, lost revenue. | A payment gateway that cannot read transaction status. |
Cache‑aside (also called lazy loading) lets an application decide when to read from or write to a cache. It keeps the cache in sync with the source of truth (the DB) while avoiding unnecessary DB traffic. It is a core pattern in microservice architectures, API gateways, and data‑access layers.
2. Mental model
Analogy: The Library & Your Personal Copy
Imagine you’re a researcher who frequently needs a specific paper.
- Without a cache: Every time you need the paper you go to the library, wait in line, and pull it out.
- With cache‑aside: The first time you fetch the paper you copy it into your own notebook (the cache). Subsequent requests read from your notebook, which is instant. If the paper gets updated in the library, you delete the old copy and fetch a fresh one.
Key take‑away: The application owns the cache logic – it decides when to look there, when to fetch from the DB, and when to refresh the cache.
3. Deep explanation
Internals
- Cache store – usually an in‑memory key/value store (Redis, Memcached).
- Key design – deterministic, namespaced keys (
user:123,product:456). - TTL (time‑to‑live) – optional expiration to avoid stale data.
- Eviction policy – LRU, LFU, FIFO, etc., to keep memory bounded.
- Serialization – JSON, MessagePack, or binary formats for efficient storage.
Important terminology
| Term | Meaning |
|---|---|
| Cache hit | Data found in cache. |
| Cache miss | Data not found; must fetch from DB. |
| Cache stampede | Many concurrent misses that overwhelm the DB. |
| Cache invalidation | Removing or updating stale entries after a write. |
| Cache warming | Pre‑loading cache with expected data before traffic spikes. |
Trade‑offs
| Trade‑off | Pros | Cons |
|---|---|---|
| Consistency vs. Latency | Strong consistency (write‑through) guarantees freshness but adds latency. | Cache‑aside offers eventual consistency but can serve stale data. |
| Complexity | Adds code for cache logic, key generation, invalidation. | More moving parts increase maintenance burden. |
| Memory cost | Requires RAM for cache; expensive at scale. | Unused cache entries waste resources. |
Failure modes
| Mode | What happens | Mitigation |
|---|---|---|
| Cache down | All requests fall back to DB. | Graceful degradation, retry logic, circuit breaker. |
| Stale data | Cache holds old value after DB update. | Proper invalidation, TTL, or write‑through. |
| Race conditions | Two writes update DB and cache inconsistently. | Use atomic operations or distributed locks. |
| Cache stampede | Many concurrent misses hit DB. | Cache‑prevention patterns (mutex, token bucket). |
Limitations
- Write‑heavy workloads: Cache‑aside is not ideal because every write requires cache invalidation.
- Small, infrequently read data: The overhead of cache logic may outweigh benefits.
- Highly dynamic data: Frequent changes make TTL ineffective; cache may become stale quickly.
Where it fits
- Data‑access layer: Service methods that fetch data from DB.
- API gateway: Caching responses to reduce backend load.
- Microservices: Shared cache for common entities (users, products).
- Event‑driven systems: Invalidate cache on domain events (e.g.,
UserUpdated).
Interaction with related concepts
| Concept | Interaction |
|---|---|
| Read‑through | Cache‑aside is a manual read‑through; read‑through automates the fetch‑and‑store step. |
| Write‑through / write‑behind | Cache‑aside leaves writes to the application; write‑through writes to both DB and cache immediately. |
| CDN | Edge caches can be considered a higher‑level cache; cache‑aside operates at application level. |
| Message queues | Events can trigger cache invalidation asynchronously. |
4. How it works
Step‑by‑step flow
- Client request arrives at the service.
- Cache lookup: Service queries Redis for the key.
- Cache hit → return cached data.
- Cache miss → query the DB.
- Store in cache: Save the DB result with an appropriate TTL.
- Return to client.
- On write: Update DB, then invalidate or update the cache entry.
sequenceDiagram
participant Client
participant App
participant Cache
participant DB
Client->>App: GET /user/42
App->>Cache: GET user:42
Cache-->>App: MISS
App->>DB: SELECT * FROM users WHERE id=42
DB-->>App: user data
App->>Cache: SET user:42 data TTL=300
App-->>Client: user data
Client->>App: PUT /user/42
App->>DB: UPDATE users SET ... WHERE id=42
DB-->>App: OK
App->>Cache: DEL user:42
App-->>Client: OK
5. Practical implementation
Below is a minimal, runnable Node.js example using TypeScript and node‑redis.
It demonstrates:
getUserById(cache‑aside read)updateUser(write + cache invalidation)
// src/cache.ts
import { createClient } from 'redis';
export const redis = createClient({
url: process.env.REDIS_URL ?? 'redis://localhost:6379',
});
redis.on('error', err => console.error('Redis error', err));
await redis.connect();
// src/db.ts
import { Pool } from 'pg';
export const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
// src/userService.ts
import { redis } from './cache';
import { pool } from './db';
import { User } from './types';
const USER_KEY = (id: number) => `user:${id}`;
const USER_TTL = 300; // seconds
export async function getUserById(id: number): Promise<User | null> {
// 1️⃣ Try cache
const cached = await redis.get(USER_KEY(id));
if (cached) {
console.log('Cache hit');
return JSON.parse(cached) as User;
}
// 2️⃣ Miss → DB
console.log('Cache miss');
const res = await pool.query('SELECT id, name, email FROM users WHERE id = $1', [id]);
if (res.rows.length === 0) return null;
const user = res.rows[0] as User;
// 3️⃣ Store in cache
await redis.set(USER_KEY(id), JSON.stringify(user), {
EX: USER_TTL,
});
return user;
}
export async function updateUser(id: number, data: Partial<User>): Promise<void> {
// 1️⃣ Update DB
const setClauses = Object.keys(data).map((k, i) => `${k} = ${i + 1}`).join(', ');
const values = Object.values(data);
values.push(id);
await pool.query(`UPDATE users SET ${setClauses} WHERE id = ${values.length}`, values);
// 2️⃣ Invalidate cache
await redis.del(USER_KEY(id));
}
Key points
USER_KEYensures deterministic namespacing.EXsets TTL; adjust based on data volatility.- Invalidation (
DEL) guarantees that subsequent reads hit the DB until the next cache fill.
6. Production considerations
| Concern | What to watch for | Practical tip |
|---|---|---|
| Scalability | Single Redis node becomes a bottleneck. | Use Redis Cluster or sharding; consider consistent hashing. |
| Reliability | Cache outages should not crash the service. | Wrap cache calls in try/catch; fallback to DB on error. |
| Security | Sensitive data in cache. | Enable TLS, use ACLs, encrypt values if needed. |
| Observability | Need to know hit/miss ratio. | Expose Redis metrics (redis_exporter) and instrument app metrics (prom-client). |
| Performance | High latency on cache miss. | Use async/await; avoid blocking I/O; consider async queue for DB fetches. |
| Error handling | Partial failures (DB up, cache down). | Circuit breaker pattern; exponential backoff for retries. |
| Resource usage | Memory pressure. | Set eviction policy (maxmemory-policy=allkeys-lru), monitor maxmemory usage. |
| Operational concerns | Cache warm‑up after restart. | Pre‑populate cache for hot keys or use a background worker. |
| Failure recovery | Cache corruption. | Periodic consistency checks between DB and cache; rebuild cache on detection. |
| Deployment | Zero‑downtime upgrades. | Use blue/green or canary releases; keep old cache keys until all instances are updated. |
7. Common mistakes
| Mistake | Why it’s bad |
|---|---|
| No cache invalidation | Stale data served forever. |
| Long TTL on volatile data | Users see outdated info. |
| Using the same key for different data types | Key collisions, hard‑to‑debug bugs. |
| Ignoring cache errors | Service crashes on Redis outage. |
| Cache stampede | Multiple concurrent misses overload DB. |
| Over‑caching | Storing everything, including small or rarely read items. |
| Not serializing properly | Binary data misinterpreted, leading to crashes. |
| Hard‑coding keys | Makes refactoring painful. |
| Not monitoring hit/miss ratio | Missed opportunities for optimization. |
| Using Redis as a primary store | Redis is not a durable DB; data loss on restart. |
8. Senior engineer perspective
What changes at scale?
- Cache partitioning: Use Redis Cluster or multiple shards to avoid a single point of contention.
- Eviction strategy: LRU may not be optimal for all workloads; consider LFU or custom policies.
- Key design: Include tenant ID, environment, or version to avoid cross‑tenant leaks.
- Consistency model: Decide between eventual consistency (cache‑aside) vs. strong consistency (write‑through).
- Monitoring: Set up dashboards for hit/miss ratio, latency, eviction count, and memory usage.
- Cost: Memory is expensive; balance TTL and eviction to keep cache size reasonable.
- Failure scenarios: Plan for network partitions, Redis node failures, and data center outages.
- Testing: Use chaos engineering to simulate cache failures and ensure graceful degradation.
- When NOT to use: If writes dominate reads, or data changes every request, a cache may add more complexity than benefit.
9. Interview questions
1BeginnerWhat is the cache‑aside pattern?
2BeginnerHow does cache‑aside differ from read‑through caching?
3SeniorHow would you prevent a cache stampede in a high‑traffic system?
4SeniorDesign a key strategy for a multi‑tenant SaaS application using cache‑aside.
tenant:{id}:user:{userId}), include versioning (v1), and use a consistent hashing scheme if sharding. Avoid collisions and make keys human‑readable for debugging.5Scenario/DebuggingAfter a deployment, the cache hit ratio dropped from 95% to 20%. What could be causing this and how would you investigate?
- Check if the new code changed the key format (e.g., added a prefix).
- Verify TTL values; maybe they were set to 0.
- Look for cache invalidation logic that deletes keys on read.
- Inspect Redis logs for evictions or memory pressure.
- Use Redis
INFO statsto seekeyspace_hitsvskeyspace_misses. - Confirm that the application is still connecting to the same Redis instance.
10. Hands‑on task
Goal: Build a small Node.js service that exposes two endpoints:
GET /product/:id– uses cache‑aside to fetch product details from PostgreSQL and cache in Redis.PUT /product/:id– updates the product in PostgreSQL and invalidates the cache.
Steps
- Setup Docker Compose
version: '3.8' services: postgres: image: postgres:15 environment: POSTGRES_USER: user POSTGRES_PASSWORD: pass POSTGRES_DB: shop ports: ["5432:5432"] redis: image: redis:7 ports: ["6379:6379"] app: build: . environment: DATABASE_URL: postgres://user:pass@postgres:5432/shop REDIS_URL: redis://redis:6379 ports: ["3000:3000"] - Create a simple schema (
productstable withid,name,price). - Implement the service using the code pattern from section 5.
- Run
docker compose upand test withcurlor Postman. - Observe cache behavior:
- First request → cache miss, DB hit.
- Subsequent request → cache hit.
- After
PUT, cache is invalidated; nextGETtriggers a miss again.
Deliverable: A GitHub repo with Dockerfile, docker-compose.yml, TypeScript source, and a README explaining how to run and test.
11. Quick revision
- Cache‑aside: application‑controlled cache read/write.
- Cache hit → fast, no DB.
- Cache miss → fetch from DB, store in cache, return.
- Write path: update DB → invalidate or update cache.
- Key design: deterministic, namespaced, tenant‑aware.
- TTL: controls staleness; balance with eviction policy.
- Cache stampede: mitigate with locks or token bucket.
- Observability: monitor hit/miss ratio, latency, evictions.
- Failure handling: graceful degradation, circuit breaker.
- Scaling: use Redis Cluster, consistent hashing, sharding.
- When not to use: write‑heavy workloads, data that changes every request.
12. References
- Redis Documentation – Key Concepts
- node‑redis v4 Docs
- PostgreSQL Official Docs
- Prometheus Redis Exporter
- Cache‑Aside Pattern – Martin Fowler
- Cache Stampede Prevention – AWS Whitepaper