Cache‑aside

Backend Fundamentals · Caching · 8 min read

1. What problem does this solve?

ProblemWhy it mattersConsequence if ignoredReal‑world example
Database overloadA 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 latencyNetwork 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.
CostScaling DBs (read replicas, sharding) is expensive.Higher infrastructure spend.E‑commerce sites that need to support flash sales.
AvailabilityIf 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

  1. Cache store – usually an in‑memory key/value store (Redis, Memcached).
  2. Key design – deterministic, namespaced keys (user:123, product:456).
  3. TTL (time‑to‑live) – optional expiration to avoid stale data.
  4. Eviction policy – LRU, LFU, FIFO, etc., to keep memory bounded.
  5. Serialization – JSON, MessagePack, or binary formats for efficient storage.

Important terminology

TermMeaning
Cache hitData found in cache.
Cache missData not found; must fetch from DB.
Cache stampedeMany concurrent misses that overwhelm the DB.
Cache invalidationRemoving or updating stale entries after a write.
Cache warmingPre‑loading cache with expected data before traffic spikes.

Trade‑offs

Trade‑offProsCons
Consistency vs. LatencyStrong consistency (write‑through) guarantees freshness but adds latency.Cache‑aside offers eventual consistency but can serve stale data.
ComplexityAdds code for cache logic, key generation, invalidation.More moving parts increase maintenance burden.
Memory costRequires RAM for cache; expensive at scale.Unused cache entries waste resources.

Failure modes

ModeWhat happensMitigation
Cache downAll requests fall back to DB.Graceful degradation, retry logic, circuit breaker.
Stale dataCache holds old value after DB update.Proper invalidation, TTL, or write‑through.
Race conditionsTwo writes update DB and cache inconsistently.Use atomic operations or distributed locks.
Cache stampedeMany 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).
ConceptInteraction
Read‑throughCache‑aside is a manual read‑through; read‑through automates the fetch‑and‑store step.
Write‑through / write‑behindCache‑aside leaves writes to the application; write‑through writes to both DB and cache immediately.
CDNEdge caches can be considered a higher‑level cache; cache‑aside operates at application level.
Message queuesEvents can trigger cache invalidation asynchronously.

4. How it works

Step‑by‑step flow

  1. Client request arrives at the service.
  2. Cache lookup: Service queries Redis for the key.
  3. Cache hit → return cached data.
  4. Cache miss → query the DB.
  5. Store in cache: Save the DB result with an appropriate TTL.
  6. Return to client.
  7. 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_KEY ensures deterministic namespacing.
  • EX sets TTL; adjust based on data volatility.
  • Invalidation (DEL) guarantees that subsequent reads hit the DB until the next cache fill.

6. Production considerations

ConcernWhat to watch forPractical tip
ScalabilitySingle Redis node becomes a bottleneck.Use Redis Cluster or sharding; consider consistent hashing.
ReliabilityCache outages should not crash the service.Wrap cache calls in try/catch; fallback to DB on error.
SecuritySensitive data in cache.Enable TLS, use ACLs, encrypt values if needed.
ObservabilityNeed to know hit/miss ratio.Expose Redis metrics (redis_exporter) and instrument app metrics (prom-client).
PerformanceHigh latency on cache miss.Use async/await; avoid blocking I/O; consider async queue for DB fetches.
Error handlingPartial failures (DB up, cache down).Circuit breaker pattern; exponential backoff for retries.
Resource usageMemory pressure.Set eviction policy (maxmemory-policy=allkeys-lru), monitor maxmemory usage.
Operational concernsCache warm‑up after restart.Pre‑populate cache for hot keys or use a background worker.
Failure recoveryCache corruption.Periodic consistency checks between DB and cache; rebuild cache on detection.
DeploymentZero‑downtime upgrades.Use blue/green or canary releases; keep old cache keys until all instances are updated.

7. Common mistakes

MistakeWhy it’s bad
No cache invalidationStale data served forever.
Long TTL on volatile dataUsers see outdated info.
Using the same key for different data typesKey collisions, hard‑to‑debug bugs.
Ignoring cache errorsService crashes on Redis outage.
Cache stampedeMultiple concurrent misses overload DB.
Over‑cachingStoring everything, including small or rarely read items.
Not serializing properlyBinary data misinterpreted, leading to crashes.
Hard‑coding keysMakes refactoring painful.
Not monitoring hit/miss ratioMissed opportunities for optimization.
Using Redis as a primary storeRedis 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?
Answer
: A pattern where the application checks the cache first; on a miss it fetches from the DB, stores the result in the cache, and returns it. Writes update the DB and invalidate the cache.
2BeginnerHow does cache‑aside differ from read‑through caching?
Answer
: Read‑through automatically loads data into the cache on a miss, often handled by the cache layer itself. Cache‑aside requires explicit code to load and store data.
3SeniorHow would you prevent a cache stampede in a high‑traffic system?
Answer
: Use a mutex or distributed lock around the cache miss path, employ a “lazy‑load with back‑off” strategy, or pre‑populate the cache (cache warming). Techniques like “double‑checked locking” or “token bucket” can also help.
4SeniorDesign a key strategy for a multi‑tenant SaaS application using cache‑aside.
Answer
: Prefix keys with tenant ID (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?
Answer
:
  • 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 stats to see keyspace_hits vs keyspace_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:

  1. GET /product/:id – uses cache‑aside to fetch product details from PostgreSQL and cache in Redis.
  2. PUT /product/:id – updates the product in PostgreSQL and invalidates the cache.

Steps

  1. 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"]
    
  2. Create a simple schema (products table with id, name, price).
  3. Implement the service using the code pattern from section 5.
  4. Run docker compose up and test with curl or Postman.
  5. Observe cache behavior:
    • First request → cache miss, DB hit.
    • Subsequent request → cache hit.
    • After PUT, cache is invalidated; next GET triggers 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