Loading learning content...
What if your cache could fetch its own data? What if, instead of scattering cache-miss handling logic throughout your application, the cache itself knew how to populate missing entries by reaching out to the underlying data store?
This is the essence of the read-through cache pattern—a caching strategy where the cache sits directly in the read path and automatically loads data from the source when a cache miss occurs. The application only ever talks to the cache; the cache handles everything else.
Unlike cache-aside, where the application orchestrates cache reads and database fallbacks, read-through abstracts this coordination into the caching layer itself. The result is cleaner application code and centralized caching logic—at the cost of reduced flexibility and control.
By the end of this page, you will understand the read-through cache pattern comprehensively—how it works, how to implement it, its relationship to other patterns, and critically, when it's the right (or wrong) choice for your system.
In the read-through pattern, the cache isn't just a performance optimization layer—it becomes the primary interface for data access. The application requests data from the cache, and the cache takes responsibility for ensuring that data is available, fetching it from the underlying data store if necessary.
The Fundamental Shift:
| Aspect | Cache-Aside | Read-Through |
|---|---|---|
| Who queries the database? | Application | Cache |
| Where is miss-handling logic? | Application code | Cache configuration |
| Application's view | "Check cache, then maybe database" | "Just ask the cache" |
| Cache's responsibility | Passive storage | Active data management |
This abstraction follows the Inversion of Control principle—the cache controls the data loading process, not the application. The application merely declares what it needs; the cache figures out how to provide it.
The name comes from how data flows: read requests go "through" the cache to the backing store when necessary. The cache intercepts reads, serving from its own storage when possible, or pulling through from the source when needed. Callers never need to go around the cache.
The read-through pattern follows a clean, predictable flow for every read request. Understanding this flow is essential for reasoning about performance, consistency, and failure modes.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112
/** * Read-Through Cache Implementation * * The cache wraps a loading function and manages data lifecycle transparently. */interface LoadingCache<K, V> { get(key: K): Promise<V | null>; invalidate(key: K): Promise<void>; invalidateAll(): Promise<void>;} type Loader<K, V> = (key: K) => Promise<V | null>; interface ReadThroughCacheOptions { ttlSeconds: number; maxSize?: number; refreshAhead?: boolean; refreshThresholdSeconds?: number;} class ReadThroughCache<K extends string | number, V> implements LoadingCache<K, V> { private cache: Map<K, { value: V | null; expiresAt: number }>; private loader: Loader<K, V>; private options: ReadThroughCacheOptions; private inFlight: Map<K, Promise<V | null>>; // For request coalescing constructor(loader: Loader<K, V>, options: ReadThroughCacheOptions) { this.cache = new Map(); this.loader = loader; this.options = options; this.inFlight = new Map(); } async get(key: K): Promise<V | null> { const now = Date.now(); const cached = this.cache.get(key); // Cache hit: return immediately if (cached && cached.expiresAt > now) { // Optional: trigger background refresh if nearing expiry if (this.options.refreshAhead && this.shouldRefreshAhead(cached, now)) { this.triggerBackgroundRefresh(key); } return cached.value; } // Cache miss: load through the configured loader return this.loadAndCache(key); } private async loadAndCache(key: K): Promise<V | null> { // Request coalescing: if already loading this key, wait for that result const existingLoad = this.inFlight.get(key); if (existingLoad) { return existingLoad; } // Start new load const loadPromise = this.executeLoad(key); this.inFlight.set(key, loadPromise); try { return await loadPromise; } finally { this.inFlight.delete(key); } } private async executeLoad(key: K): Promise<V | null> { const value = await this.loader(key); // Store in cache (even null values for negative caching) const expiresAt = Date.now() + (this.options.ttlSeconds * 1000); this.cache.set(key, { value, expiresAt }); // Evict if over max size (LRU would be more sophisticated) if (this.options.maxSize && this.cache.size > this.options.maxSize) { this.evictOldest(); } return value; } private shouldRefreshAhead(cached: { expiresAt: number }, now: number): boolean { if (!this.options.refreshThresholdSeconds) return false; const remainingLife = (cached.expiresAt - now) / 1000; return remainingLife < this.options.refreshThresholdSeconds; } private triggerBackgroundRefresh(key: K): void { // Fire-and-forget refresh—don't await, don't block this.executeLoad(key).catch(err => { console.error(`Background refresh failed for key ${key}:`, err); }); } private evictOldest(): void { // Simple eviction: remove the first (oldest) entry const firstKey = this.cache.keys().next().value; if (firstKey !== undefined) { this.cache.delete(firstKey); } } async invalidate(key: K): Promise<void> { this.cache.delete(key); } async invalidateAll(): Promise<void> { this.cache.clear(); }}Key observations from this implementation:
The loader is configuration, not per-call — The loading function is provided at cache construction, not at each get() call. This centralizes data fetching logic.
Request coalescing is built-in — When multiple requests arrive for the same missing key, only one loader invocation occurs. This prevents cache stampedes.
Refresh-ahead extends the pattern — Optional proactive refresh before expiration keeps data fresh without incurring miss latency.
The application code is minimal — Callers just call cache.get(key). No fallback logic, no database calls, no cache population.
The most visible benefit of read-through caching is the dramatic simplification of application code. Compare the calling patterns:
Cache-Aside (application manages everything):
const user = await cache.get(userId);
if (!user) {
const dbUser = await database.getUser(userId);
if (dbUser) {
await cache.set(userId, dbUser);
}
return dbUser;
}
return user;
Read-Through (cache manages loading):
return await cache.get(userId);
The read-through version is not only shorter—it's conceptually simpler. Developers using the cache don't need to understand loading logic, TTLs, or database connections. They just request data.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455
// ===== Setting up the Read-Through Cache ===== // Define the loader function onceasync function loadUser(userId: string): Promise<User | null> { return database.query<User>( 'SELECT * FROM users WHERE id = $1', [userId] );} // Create the cache with the loaderconst userCache = new ReadThroughCache<string, User>(loadUser, { ttlSeconds: 3600, // 1 hour TTL maxSize: 10000, // Max 10k entries refreshAhead: true, // Enable proactive refresh refreshThresholdSeconds: 300 // Refresh when <5 min remaining}); // ===== Using the Cache (anywhere in the application) ===== class UserService { async getUser(userId: string): Promise<User | null> { // One line. No fallback logic. No database awareness. return userCache.get(userId); } async getUserOrThrow(userId: string): Promise<User> { const user = await userCache.get(userId); if (!user) { throw new NotFoundError(`User ${userId} not found`); } return user; } async getUserPreferences(userId: string): Promise<UserPreferences> { const user = await this.getUser(userId); return user?.preferences ?? defaultPreferences; }} // ===== Multiple Caches with Different Loaders ===== const productCache = new ReadThroughCache<string, Product>( async (productId) => database.query('SELECT * FROM products WHERE id = $1', [productId]), { ttlSeconds: 1800 } // 30 minutes); const orderCache = new ReadThroughCache<string, Order[]>( async (userId) => database.query('SELECT * FROM orders WHERE user_id = $1 ORDER BY created_at DESC', [userId]), { ttlSeconds: 300 } // 5 minutes - orders change frequently); // Usage remains uniform across all cachesconst product = await productCache.get(productId);const orders = await orderCache.get(userId);A common pattern is creating separate read-through caches for different entity types, each with its own loader and TTL. User caches might have 1-hour TTL; product caches might have 30 minutes; real-time data might have 30 seconds. This allows fine-grained control while maintaining the clean calling interface.
The loader function is the heart of a read-through cache. Its design determines performance, reliability, and correctness. A poorly designed loader can undermine all caching benefits.
Loader Design Principles:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687
interface LoaderConfig { timeout: number; retries: number; retryDelay: number;} /** * Production-quality loader with timeout, retries, and instrumentation */function createRobustLoader<K, V>( name: string, fetchFn: (key: K) => Promise<V | null>, config: LoaderConfig, metrics: MetricsClient): Loader<K, V> { return async (key: K): Promise<V | null> => { const startTime = Date.now(); let lastError: Error | null = null; for (let attempt = 0; attempt <= config.retries; attempt++) { try { // Execute with timeout const result = await withTimeout( fetchFn(key), config.timeout, `Loader ${name} timed out for key ${key}` ); // Record success metrics const duration = Date.now() - startTime; metrics.timing(`cache.loader.${name}.duration`, duration); metrics.increment(`cache.loader.${name}.success`); return result; } catch (error) { lastError = error as Error; metrics.increment(`cache.loader.${name}.error`, { attempt: String(attempt), errorType: (error as Error).name }); // Don't retry on the last attempt if (attempt < config.retries) { await sleep(config.retryDelay * Math.pow(2, attempt)); // Exponential backoff } } } // All retries exhausted metrics.increment(`cache.loader.${name}.exhausted`); // Decision: throw vs return null // Throwing propagates the error to callers (fail-fast) // Returning null might cache the null (if negative caching is enabled) throw new LoaderError( `Loader ${name} failed after ${config.retries + 1} attempts`, lastError ); };} // Utility function for timeoutfunction withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> { return new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new TimeoutError(message)), ms); promise .then(value => { clearTimeout(timer); resolve(value); }) .catch(err => { clearTimeout(timer); reject(err); }); });} // Usageconst userLoader = createRobustLoader<string, User>( 'user', async (userId) => database.query<User>('SELECT * FROM users WHERE id = $1', [userId]), { timeout: 2000, retries: 2, retryDelay: 100 }, metricsClient); const userCache = new ReadThroughCache<string, User>(userLoader, { ttlSeconds: 3600 });Be careful with error handling. If your loader throws an error and the cache stores it, subsequent callers will receive the cached error—even if the underlying issue is resolved. Either propagate errors without caching, or use very short TTLs for error states.
A powerful extension to read-through caching is refresh-ahead (also called proactive refresh or background refresh). Instead of waiting for entries to expire completely, the cache refreshes popular entries before they expire.
The Problem with Simple TTL Expiration:
With standard TTL expiration, when a cached entry expires, the next request experiences the full latency of loading from the database. If many requests arrive for an expired entry simultaneously, you face a cache stampede.
Refresh-Ahead Solution:
The cache monitors entry age. When an entry is accessed and its remaining TTL is below a threshold, the cache triggers a background refresh while still serving the (slightly stale) cached data. By the time the entry would have expired, it's already been refreshed.
Timeline (1-hour TTL, 5-minute refresh threshold):
[0:00] Entry cached, TTL = 60 min
[0:55] Request arrives, 5 min remaining → trigger background refresh
[0:55] Return cached data immediately (no latency hit)
[0:55] Background: loader fetches fresh data
[0:56] Fresh data written to cache, new TTL = 60 min
[1:00] Original entry would have expired, but we already refreshed
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192
interface CacheEntry<V> { value: V | null; expiresAt: number; createdAt: number; refreshInProgress: boolean; // Prevent duplicate refreshes} class RefreshAheadCache<K extends string, V> { private cache: Map<K, CacheEntry<V>>; private loader: Loader<K, V>; private ttlMs: number; private refreshThresholdMs: number; constructor( loader: Loader<K, V>, ttlSeconds: number, refreshThresholdSeconds: number ) { this.cache = new Map(); this.loader = loader; this.ttlMs = ttlSeconds * 1000; this.refreshThresholdMs = refreshThresholdSeconds * 1000; } async get(key: K): Promise<V | null> { const now = Date.now(); const entry = this.cache.get(key); if (entry && entry.expiresAt > now) { // Cache hit const remainingLife = entry.expiresAt - now; // Check if we should refresh ahead if (remainingLife < this.refreshThresholdMs && !entry.refreshInProgress) { this.refreshAhead(key, entry); } return entry.value; } // Cache miss - synchronous load return this.loadSync(key); } private async loadSync(key: K): Promise<V | null> { const value = await this.loader(key); const now = Date.now(); this.cache.set(key, { value, expiresAt: now + this.ttlMs, createdAt: now, refreshInProgress: false }); return value; } private refreshAhead(key: K, entry: CacheEntry<V>): void { // Mark as refresh in progress to prevent duplicate refreshes entry.refreshInProgress = true; // Fire-and-forget background refresh this.loader(key) .then(freshValue => { const now = Date.now(); this.cache.set(key, { value: freshValue, expiresAt: now + this.ttlMs, createdAt: now, refreshInProgress: false }); console.log(`Refresh-ahead completed for key: ${key}`); }) .catch(error => { // Refresh failed - just log it // The old entry is still valid until it expires console.error(`Refresh-ahead failed for key ${key}:`, error); entry.refreshInProgress = false; }); }} // Usage: 1-hour TTL, refresh when 5 minutes remainingconst cache = new RefreshAheadCache<string, User>( userLoader, 3600, // 1 hour 300 // 5 minutes); // From the caller's perspective, nothing changesconst user = await cache.get(userId);| Benefit | Cost |
|---|---|
| Eliminates expiration-time latency spikes | Increases background load on data source |
| Prevents cache stampedes | Popular items refreshed more than needed |
| Users never wait for cache reloads | Slightly stale data served during refresh |
| Smoother latency distribution | More complex cache implementation |
Set the refresh threshold based on your loader latency. If loading takes 200ms, a 30-second threshold gives plenty of margin. If loading takes 5 seconds (e.g., complex aggregation), a 1-minute threshold ensures the refresh completes before expiration.
Read-through and cache-aside are both pull-based caching patterns—they load data on demand. But their architectural differences lead to distinct tradeoffs:
Philosophical Difference:
| Aspect | Cache-Aside | Read-Through |
|---|---|---|
| Loading Responsibility | Application code | Cache infrastructure |
| Application Complexity | Higher—must handle misses | Lower—just call get() |
| Flexibility | Maximum—any loading logic | Constrained—loader predefined |
| Multi-source Loading | Easy—different logic per call | Hard—one loader per cache |
| Request Coalescing | Must implement manually | Often built into cache |
| Error Handling | Application decides per-call | Loader policy applies uniformly |
| Testing | Mock cache OR database | Mock loader OR cache |
| Debugging | Logic in application code | Logic in cache configuration |
| Library Support | Any cache (Redis, Memcached) | Specialized libraries needed |
Many production systems use both patterns. Read-through for simple entity lookups (users, products) where loading is uniform; cache-aside for complex queries or multi-source aggregations where loading logic varies. Choose the pattern that fits each use case.
Several popular caching libraries and systems provide read-through semantics out of the box. Understanding these implementations helps you leverage existing infrastructure rather than building from scratch.
12345678910111213141516171819202122232425262728293031
// Java: Caffeine (successor to Guava Cache)// One of the most popular read-through implementations import com.github.benmanes.caffeine.cache.Caffeine;import com.github.benmanes.caffeine.cache.LoadingCache;import com.github.benmanes.caffeine.cache.CacheLoader; // Define the loaderCacheLoader<String, User> userLoader = userId -> { // This runs on cache miss return database.findUserById(userId);}; // Build the read-through cacheLoadingCache<String, User> userCache = Caffeine.newBuilder() .maximumSize(10_000) // Max entries .expireAfterWrite(Duration.ofHours(1)) // TTL .refreshAfterWrite(Duration.ofMinutes(55)) // Refresh-ahead! .recordStats() // Enable metrics .build(userLoader); // Usage - the cache handles loading transparentlyUser user = userCache.get(userId); // Blocks until loaded if not cached // Bulk loading (also supported)Map<String, User> users = userCache.getAll(List.of("id1", "id2", "id3")); // StatisticsCacheStats stats = userCache.stats();System.out.println("Hit rate: " + stats.hitRate());System.out.println("Load count: " + stats.loadCount());12345678910111213141516171819202122232425262728293031323334353637383940
// Node.js: Using lru-cache with fetchMethod (read-through) import { LRUCache } from 'lru-cache'; interface User { id: string; name: string; email: string;} const userCache = new LRUCache<string, User>({ max: 10000, ttl: 1000 * 60 * 60, // 1 hour // The fetchMethod enables read-through behavior fetchMethod: async (userId: string): Promise<User | undefined> => { console.log(`Loading user ${userId} from database`); const user = await database.query<User>( 'SELECT * FROM users WHERE id = $1', [userId] ); return user ?? undefined; }, // Allow stale data while refreshing (refresh-ahead) allowStale: true, // Don't block concurrent fetches for the same key noDeleteOnFetchRejection: true,}); // Usage - read-through semanticsasync function getUser(userId: string): Promise<User | undefined> { // fetch() uses the fetchMethod on miss return userCache.fetch(userId);} const user = await getUser('12345'); // Loads via fetchMethod if not cachedProduction considerations:
Monitor cache statistics — Track hit/miss ratios, load times, and eviction rates. Read-through caches provide this data; use it.
Configure appropriate sizes — An undersized cache means constant evictions and reloads. An oversized cache wastes memory. Profile your access patterns.
Handle loader failures gracefully — Some libraries cache exceptions; others propagate them. Know your library's behavior.
Use bulk loading when available — Many read-through caches support getAll() that batches loader calls. This is far more efficient than individual gets.
Consider distributed caching — In-process read-through caches work great for single-node applications. For distributed systems, consider solutions like Redis with application-level read-through logic.
The read-through cache pattern shifts loading responsibility from application code to the cache itself. This abstraction simplifies calling code while centralizing and standardizing data fetching logic.
cache.get(key).What's Next:
In the next page, we'll explore the write-through cache pattern, where writes flow through the cache to the database. Unlike read-through (which handles the read path), write-through addresses consistency on the write path—ensuring the cache and database stay synchronized on every update.
You now understand the read-through cache pattern—how the cache intercepts reads and loads missing data automatically, how refresh-ahead extends the pattern, and when read-through is preferable to cache-aside. This knowledge prepares you for the write-side patterns that complete the caching picture.