Read-Through Caching: Automatic Cache Population on Cache Miss
In this tutorial, you will learn about Read. We cover key concepts, practical examples, and best practices to help you master this topic.
Read-through Caching moves the responsibility of populating the cache from the application to the cache layer itself. When a cache miss occurs, the cache automatically fetches the data from the database (or calls a loader function), caches it, and returns it — all transparently to the application.
sequenceDiagram
participant App as Application
participant Cache as Cache (Read-Through)
participant DB as Database
App->>Cache: GET key
Cache->>Cache: Cache Miss
Cache->>DB: SELECT * FROM table WHERE ...
DB-->>Cache: Row Data
Cache->>Cache: Store in Cache with TTL
Cache-->>App: Return Data
App->>Cache: GET same key
Cache-->>App: Return Cached Data (Hit)
What You'll Learn
- Implementing read-through caching with Redis client-side caching and custom loaders
- Read-through vs cache-aside: when to use each
- Cache loader functions and error handling
- Distributed read-through with Redis Cluster
Why It Matters
Read-through caches simplify application code by removing explicit cache-check-populate logic. They also ensure consistent cache behavior across all services that use the same cache layer, preventing some services from forgetting to cache.
Real-World Use
A microservice architecture uses a shared Redis read-through cache for customer data. Each service calls cache.get('customer:123', loadCustomerFromDB). The cache layer handles miss population consistently, eliminating duplicate caching logic across 15 Microservices.
Read-Through Implementations
Custom Read-Through Cache
class ReadThroughCache {
constructor(cacheBackend, loaderFn, ttl = 3600) {
this.cache = cacheBackend;
this.loader = loaderFn;
this.ttl = ttl;
this.pending = new Map();
}
async get(key) {
const cached = await this.cache.get(key);
if (cached !== null && cached !== undefined) {
return JSON.parse(cached);
}
// Deduplicate concurrent misses for same key
if (this.pending.has(key)) {
return this.pending.get(key);
}
const promise = this.loadFromSource(key);
this.pending.set(key, promise);
try {
return await promise;
} finally {
this.pending.delete(key);
}
}
async loadFromSource(key) {
try {
const data = await this.loader(key);
if (data !== null && data !== undefined) {
await this.cache.setEx(key, this.ttl, JSON.stringify(data));
}
return data;
} catch (err) {
console.error(`Read-through load failed for ${key}:`, err.message);
throw err;
}
}
}
// Usage
const userCache = new ReadThroughCache(
redisClient,
async (key) => {
const id = key.replace('user:', '');
const [rows] = await db.query('SELECT * FROM users WHERE id = ?', [id]);
return rows[0] || null;
},
1800
);
Expected output:
First call: cache miss, loader fetches from DB, caches, returns. Subsequent calls: cache hit. Concurrent misses coalesce into one loader call.
Read-Through with Redis Client-Side Caching
const Redis = require('ioredis');
const redis = new Redis({
host: 'redis-server',
enableAutoPipelining: true,
scripts: {
getOrLoad: `
local cached = redis.call('GET', KEYS[1])
if cached then return cached end
local data = redis.call('HGET', 'loaders', KEYS[1])
return data
`
}
});
async function readThroughWithLua(key, loader) {
const cached = await redis.call('GET', key);
if (cached) return JSON.parse(cached);
const data = await loader(key);
if (data) {
await redis.setex(key, 3600, JSON.stringify(data));
}
return data;
}
Expected output:
Lua script atomically checks cache and returns cached value. Application loader is called only on miss.
Read-Through with Node-cache-manager
const cacheManager = require('cache-manager');
const redisStore = require('cache-manager-ioredis');
const cache = cacheManager.caching({
store: redisStore,
redis: { host: 'redis', port: 6379 },
ttl: 600
});
async function getSettings(userId) {
return cache.wrap(`settings:${userId}`, async () => {
const [rows] = await db.query('SELECT * FROM settings WHERE user_id = ?', [userId]);
return rows[0] || {};
});
}
Expected output:
cache.wrap() implements read-through: if key exists in cache, returns it. Otherwise calls the factory function, stores result, and returns it.
Common Mistakes
- Using read-through for write-heavy data — the cache loader will be called on every read miss, but if data changes frequently, TTLs must be short, reducing efficiency.
- Not handling loader failures — if the database is down, the read-through cache should not crash. Return stale cached data if available, or throw a graceful error.
- Forgetting to invalidate the cache on writes — read-through does not handle this automatically. Combine with write-through or explicit invalidation.
- Using a single loader function for all key types — different keys may need different loaders (e.g., user vs. product vs. order).
- Not setting a TTL — without TTL, cached data lives forever and stale data is never refreshed.
Practice Questions
- How does read-through caching differ from cache-aside?
- What responsibility does the application lose when using read-through?
- How do read-through caches handle concurrent misses?
- What happens when the loader function throws an error?
- How do you invalidate specific keys in a read-through cache?
Challenge
Implement a read-through cache for a blog platform. Create a generic ReadThroughCache class that accepts different loader functions per key pattern (user:, post:, comment:*). Use Redis Sorted Sets to support paginated listing keys. Handle cache invalidation on writes.
FAQ
Mini Project
Build a read-through cache library that wraps Redis. Support key-prefix-based loader registration (e.g., cache.registerLoader('user:*', loadUser)). Implement concurrent miss coalescing, error handling with stale data fallback, and automatic invalidation on writes.
What's Next
Continue with Cache Stampede to understand how to prevent thundering herd problems at scale.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro