Read‑through cache

Backend Fundamentals · Caching · 9 min read

1. What problem does this solve?

Motivation
Backends often need to serve the same data to many users.
Fetching that data from a database on every request is expensive in latency, CPU, and network usage.
A cache can hold frequently requested data in memory (e.g. Redis), but the cache itself can become stale or empty.
A read‑through cache automatically loads missing data from the origin (database, API, etc.) and stores it in the cache for future requests.

What problem exists?

  • High latency and load on the primary data store.
  • Inconsistent data between cache and source.
  • Manual cache‑population logic scattered across code.

Why the problem matters

  • Users experience slow responses or timeouts.
  • The database can become a bottleneck or single point of failure.
  • Developers spend time writing boilerplate cache‑fallback code.

What happens if this concept is not used

  • Every request hits the database → higher cost, lower throughput.
  • Harder to scale horizontally; each new server must warm its own cache.
  • Increased risk of cache‑stale data if manual invalidation is missed.

Where this concept appears in real backend systems

  • E‑commerce product catalogs (cache product details, load from Postgres on miss).
  • Social media feeds (cache user timelines, load from MongoDB).
  • AI inference services (cache model metadata, load from S3 or a config DB).
  • Microservice architectures where services call each other over HTTP; a service can cache responses from another service.

2. Mental model

Simple analogy
Imagine a library with a quick‑look desk.
When a patron asks for a book, the desk clerk first checks a small collection of popular titles.
If the book is there, it’s handed out instantly.
If it’s missing, the clerk goes to the main shelves, retrieves the book, puts a copy on the quick‑look desk for future patrons, and then gives it to the current patron.
The clerk never has to remember where every book is; the desk automatically keeps the most requested ones handy.

Precise engineering model

  • Cache: In‑memory store (Redis, Memcached, in‑process Map).
  • Origin: Primary data source (SQL, NoSQL, external API).
  • Read‑through: On a cache miss, the system transparently reads from the origin, writes the result into the cache, and returns it to the caller.
  • Cache key: Deterministic identifier (e.g., user:123:profile).
  • TTL / eviction: Optional expiration to keep data fresh.

3. Deep explanation

Internals

  1. Lookup – The application checks the cache for a key.
  2. Miss handling – If the key is absent, a loader function is invoked.
  3. Loader – Reads from the origin (DB query, HTTP call).
  4. Write-back – The loader writes the result into the cache (optionally with TTL).
  5. Return – The data is returned to the caller.

Important terminology

  • Cache miss / hit – Whether the key was found.
  • Loader / fetcher – Function that retrieves data from the origin.
  • TTL (time‑to‑live) – Expiration time for cache entries.
  • Stale‑while‑revalidate – Serve stale data while refreshing in the background.
  • Cache stampede – Many concurrent misses for the same key causing overload on the origin.

Trade‑offs

AspectBenefitDrawback
LatencySub‑millisecond cache readsMisses still hit origin
ConsistencyAutomatic population keeps cache in syncStale data until TTL expires
ComplexitySingle point of cache logicNeed to handle loader failures
Resource usageReduced DB loadCache memory consumption

Failure modes

  • Loader failure – Origin is down; cache remains empty → repeated misses.
  • Cache corruption – Wrong data type or serialization error.
  • Race conditions – Multiple loaders for same key → cache stampede.

Limitations

  • Write path – Read‑through does not automatically propagate writes back to the cache; you must invalidate or update manually.
  • Large objects – Storing huge blobs in memory may be wasteful.
  • Non‑idempotent loaders – If the loader has side effects, repeated calls can be problematic.

Where it fits in a backend architecture

Client
  │
  ▼
API Gateway → Service
  │
  ├─► Cache (Redis)
  │
  └─► Origin (PostgreSQL / MongoDB)

The service exposes a single method getUserProfile(id) that internally uses a read‑through cache.

  • Cache invalidation – Must be triggered on writes.
  • Write‑through / write‑back – Complementary patterns for keeping cache and origin in sync.
  • Cache warming – Pre‑populate cache during deployment.
  • Distributed locking – Prevent stampedes.
  • Observability – Cache hit/miss metrics feed into monitoring.

4. How it works

  1. Client request → Service receives GET /users/:id.
  2. Cache lookup – Service checks Redis for key user:{id}.
  3. Cache hit – Return data immediately.
  4. Cache miss – Service calls loadUserFromDB(id).
  5. Loader – Executes SQL query, serializes result.
  6. Write‑back – Store result in Redis with TTL.
  7. Return – Send data to client.
sequenceDiagram
    participant C as Client
    participant S as Service
    participant R as Redis
    participant DB as PostgreSQL

    C->>S: GET /users/42
    S->>R: GET user:42
    alt Hit
        R-->>S: user data
    else Miss
        R-->>S: null
        S->>DB: SELECT * FROM users WHERE id=42
        DB-->>S: user row
        S->>R: SET user:42 <data> EX 3600
        R-->>S: OK
    end
    S-->>C: user data

5. Practical implementation

Below is a minimal, runnable example using Node.js, TypeScript, and Redis.
It demonstrates a read‑through cache for user profiles stored in PostgreSQL.

// src/cache.ts
import { createClient, RedisClientType } from 'redis';
import { Pool } from 'pg';

const redis: RedisClientType = createClient();
await redis.connect();

const pgPool = new Pool({
  connectionString: process.env.DATABASE_URL,
});

/**
 * Read‑through cache for user profiles.
 * @param userId - Primary key of the user.
 */
export async function getUserProfile(userId: string) {
  const cacheKey = `user:${userId}`;

  // 1️⃣ Try cache
  const cached = await redis.get(cacheKey);
  if (cached) {
    console.log('Cache hit');
    return JSON.parse(cached);
  }

  console.log('Cache miss – loading from DB');

  // 2️⃣ Load from DB
  const res = await pgPool.query(
    'SELECT id, name, email, updated_at FROM users WHERE id = $1',
    [userId]
  );
  if (res.rowCount === 0) throw new Error('User not found');

  const user = res.rows[0];

  // 3️⃣ Write back to cache with TTL (e.g., 5 minutes)
  await redis.set(cacheKey, JSON.stringify(user), {
    EX: 300, // seconds
  });

  return user;
}
// src/server.ts
import express from 'express';
import { getUserProfile } from './cache';

const app = express();

app.get('/users/:id', async (req, res) => {
  try {
    const user = await getUserProfile(req.params.id);
    res.json(user);
  } catch (err: any) {
    res.status(404).json({ error: err.message });
  }
});

app.listen(3000, () => console.log('🚀 Server listening on 3000'));

Running the example

# 1️⃣ Start Redis
docker run -p 6379:6379 redis:7

# 2️⃣ Start PostgreSQL (or use an existing DB)
docker run -p 5432:5432 -e POSTGRES_PASSWORD=secret postgres:15

# 3️⃣ Seed a user (psql or a migration tool)
# 4️⃣ Set env vars
export DATABASE_URL=postgres://postgres:secret@localhost:5432/postgres
node dist/server.js

Now hitting GET http://localhost:3000/users/1 will first miss the cache, load from Postgres, cache the result, and subsequent requests will hit Redis.


6. Production considerations

ConcernWhat to watch forMitigation
ScalabilityCache size grows with traffic; many services may share the same Redis cluster.Use sharding, separate namespaces, or dedicated cache clusters per service.
ReliabilityRedis downtime → cache misses; DB downtime → loader failures.Add fallback logic, circuit breakers, and retry with backoff.
SecuritySensitive data in cache; unauthorized access.Use Redis ACLs, TLS, and encrypt sensitive fields before caching.
ObservabilityNeed hit/miss metrics, latency, error rates.Instrument with Prometheus counters (cache_hits, cache_misses) and trace spans.
PerformanceSerialization overhead; network latency to Redis.Use binary formats (e.g., MessagePack), keep TTL short, colocate Redis with app nodes.
Error handlingLoader throws → cache stays empty.Catch errors, log, and return fallback or error response.
Resource usageMemory pressure; eviction policy.Monitor memory usage, set appropriate eviction policy (volatile-lru).
Operational concernsCache warm‑up, key naming conventions, TTL management.Automate cache warming scripts; enforce key prefixes; document TTL policies.
Failure recoveryCache stampede on high traffic.Use distributed locks (Redlock) or a “single flight” pattern to coalesce concurrent loaders.
DeploymentRolling updates may invalidate cache.Use graceful shutdown, keep cache keys stable, or migrate data in place.

7. Common mistakes

MistakeWhy it’s problematic
Caching writes without invalidationStale data served to users; violates consistency.
Using a very long TTLData becomes stale; users see outdated info.
Storing huge blobsWastes memory; increases eviction risk.
Ignoring cache missesNot logging misses leads to hidden performance regressions.
Not handling loader errorsRepeated misses cause repeated DB hits, amplifying load.
Using a single shared key for many usersCache collisions; data overwritten.
No distributed lock on missCache stampede: many DB queries for same key.
Hard‑coding cache keysTypos or changes break cache; leads to cache misses.
Not monitoring hit/miss ratioCannot detect cache effectiveness or misconfigurations.

8. Senior engineer perspective

What changes at scale

  • Cache partitioning – Multiple Redis shards or clusters to avoid a single bottleneck.
  • Advanced eviction – Use volatile-lru or allkeys-lru with fine‑tuned memory limits.
  • Multi‑region deployment – Replicate cache across regions for low latency.
  • Cache-aside vs. read‑through – Evaluate whether read‑through is the right pattern; sometimes a separate cache layer (e.g., CDN) is better.

Design decisions

  • Key strategy – Prefixes, hash tags for sharding, composite keys for composite lookups.
  • Loader implementation – Batch queries for multiple keys to reduce round‑trips.
  • Failure handling – Circuit breaker thresholds, fallback to stale data.
  • Monitoring – Set up alerts for hit/miss ratio drops, cache evictions, and memory pressure.
  • Cost – Redis memory cost vs. DB query cost; balance TTL and eviction policy.
  • When NOT to use – For data that changes every request (e.g., real‑time analytics), or when strict consistency is required and write‑through is simpler.

9. Interview questions

1BeginnerWhat is a read‑through cache and how does it differ from a write‑through cache?
Answer
A read‑through cache automatically loads missing data from the origin on a cache miss, while a write‑through cache writes data to both cache and origin on every write.
2BeginnerWhy do we need a TTL in a cache?
Answer
TTL ensures cached data eventually expires, preventing stale data from being served indefinitely and controlling memory usage.
3SeniorDescribe a strategy to prevent a cache stampede when many concurrent requests miss the same key.
Answer
Use a distributed lock (e.g., Redlock) or a single‑flight pattern to ensure only one loader runs; others wait for the result or serve stale data.
4SeniorHow would you design a read‑through cache for a system that has both read‑heavy and write‑heavy workloads, ensuring consistency?
Answer
Combine read‑through with write‑through or write‑back; invalidate or update cache on writes; use versioning or timestamps; consider optimistic concurrency control.
5Scenario/DebuggingA production service shows a sudden drop in cache hit ratio and increased latency. What steps would you take to diagnose and fix the issue?
Answer
  • Check cache metrics (hits, misses, evictions).
  • Verify TTL values and memory usage.
  • Inspect loader logs for failures or increased DB latency.
  • Look for recent code changes that altered key patterns or invalidation logic.
  • If a stampede, add distributed locking.
  • Consider scaling Redis or adding read replicas.

10. Hands‑on task

Goal: Implement a stale‑while‑revalidate read‑through cache for a product catalog.

  1. Create a new endpoint GET /products/:id that uses the existing getUserProfile pattern.
  2. Add a background refresh: when a cache miss occurs, serve the data from the DB immediately but also start a background job that updates the cache after the first response.
  3. Add metrics: expose Prometheus counters for cache_hits, cache_misses, db_reads.
  4. Write a test that simulates 100 concurrent requests for the same product ID and verifies that only one DB query occurs.

Hints: Use async-lock or redlock for the single‑flight pattern; use prom-client for metrics; use Jest or Mocha for tests.


11. Quick revision

  • A read‑through cache automatically loads missing data from the origin.
  • Cache hit → instant response; miss → loader, cache write, then return.
  • Key design: deterministic, namespaced, avoid collisions.
  • TTL controls staleness and memory usage.
  • Cache stampede → use distributed lock or single‑flight.
  • Invalidate or update cache on writes to maintain consistency.
  • Monitor hit/miss ratio, latency, evictions, and memory usage.
  • Use Prometheus counters (cache_hits, cache_misses, db_reads).
  • For high traffic, shard Redis or use multiple clusters.
  • Avoid caching large blobs; consider compression or binary formats.
  • Security: enable TLS, ACLs, encrypt sensitive fields.
  • Failure handling: retry with backoff, circuit breaker, fallback to stale data.

12. References