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
- Lookup – The application checks the cache for a key.
- Miss handling – If the key is absent, a loader function is invoked.
- Loader – Reads from the origin (DB query, HTTP call).
- Write-back – The loader writes the result into the cache (optionally with TTL).
- 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
| Aspect | Benefit | Drawback |
|---|---|---|
| Latency | Sub‑millisecond cache reads | Misses still hit origin |
| Consistency | Automatic population keeps cache in sync | Stale data until TTL expires |
| Complexity | Single point of cache logic | Need to handle loader failures |
| Resource usage | Reduced DB load | Cache 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.
Interaction with related concepts
- 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
- Client request → Service receives
GET /users/:id. - Cache lookup – Service checks Redis for key
user:{id}. - Cache hit – Return data immediately.
- Cache miss – Service calls
loadUserFromDB(id). - Loader – Executes SQL query, serializes result.
- Write‑back – Store result in Redis with TTL.
- 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
| Concern | What to watch for | Mitigation |
|---|---|---|
| Scalability | Cache size grows with traffic; many services may share the same Redis cluster. | Use sharding, separate namespaces, or dedicated cache clusters per service. |
| Reliability | Redis downtime → cache misses; DB downtime → loader failures. | Add fallback logic, circuit breakers, and retry with backoff. |
| Security | Sensitive data in cache; unauthorized access. | Use Redis ACLs, TLS, and encrypt sensitive fields before caching. |
| Observability | Need hit/miss metrics, latency, error rates. | Instrument with Prometheus counters (cache_hits, cache_misses) and trace spans. |
| Performance | Serialization overhead; network latency to Redis. | Use binary formats (e.g., MessagePack), keep TTL short, colocate Redis with app nodes. |
| Error handling | Loader throws → cache stays empty. | Catch errors, log, and return fallback or error response. |
| Resource usage | Memory pressure; eviction policy. | Monitor memory usage, set appropriate eviction policy (volatile-lru). |
| Operational concerns | Cache warm‑up, key naming conventions, TTL management. | Automate cache warming scripts; enforce key prefixes; document TTL policies. |
| Failure recovery | Cache stampede on high traffic. | Use distributed locks (Redlock) or a “single flight” pattern to coalesce concurrent loaders. |
| Deployment | Rolling updates may invalidate cache. | Use graceful shutdown, keep cache keys stable, or migrate data in place. |
7. Common mistakes
| Mistake | Why it’s problematic |
|---|---|
| Caching writes without invalidation | Stale data served to users; violates consistency. |
| Using a very long TTL | Data becomes stale; users see outdated info. |
| Storing huge blobs | Wastes memory; increases eviction risk. |
| Ignoring cache misses | Not logging misses leads to hidden performance regressions. |
| Not handling loader errors | Repeated misses cause repeated DB hits, amplifying load. |
| Using a single shared key for many users | Cache collisions; data overwritten. |
| No distributed lock on miss | Cache stampede: many DB queries for same key. |
| Hard‑coding cache keys | Typos or changes break cache; leads to cache misses. |
| Not monitoring hit/miss ratio | Cannot 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-lruorallkeys-lruwith 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?
2BeginnerWhy do we need a TTL in a cache?
3SeniorDescribe a strategy to prevent a cache stampede when many concurrent requests miss the same key.
4SeniorHow would you design a read‑through cache for a system that has both read‑heavy and write‑heavy workloads, ensuring consistency?
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?
- 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.
- Create a new endpoint
GET /products/:idthat uses the existinggetUserProfilepattern. - 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.
- Add metrics: expose Prometheus counters for
cache_hits,cache_misses,db_reads. - 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
- Redis Documentation – Cache Patterns
- Node.js Redis Client (ioredis)
- PostgreSQL Node.js Driver
- Prometheus Node.js Client
- Redlock Algorithm
- OpenTelemetry for Node.js