What Is a Read-Through Cache?
In a read-through cache, the cache layer itself is responsible for loading data from the database on a miss. The application calls cache.get(key) and always receives a value — the cache either returns a cached entry or transparently fetches it from the DB, populates the cache, and returns it. The application has no explicit cache-miss handling logic.
This is in contrast to a cache-aside (lazy loading) pattern, where the application checks the cache, handles a miss itself by querying the DB, and manually populates the cache. Read-through centralizes that logic in the cache layer.
Cache Loader: Pluggable Population
A cache loader is a function registered per key pattern that the cache calls on a miss. For example:
- Key pattern
user:*→ loader queries theuserstable by user ID. - Key pattern
product:*→ loader queries theproductstable.
Loaders are registered at startup. The cache calls loader(key) when the pattern matches a missed key, populates the cache with the result, and returns the value to the caller — all transparently.
Stale-While-Revalidate
Stale-while-revalidate (SWR) is a technique to eliminate cache-miss latency at the cost of brief staleness. Instead of blocking the caller while the cache refreshes an expired entry:
- Serve the stale value immediately.
- Trigger an async background refresh to reload from DB.
- The next request (after refresh completes) gets the fresh value.
SWR requires two TTL values per entry: a fresh TTL (serve without refresh) and a stale TTL (serve stale while refreshing). Once the stale TTL expires, the entry is considered fully expired and the next request blocks on a synchronous load.
Versioned Cache Entries
For stronger consistency, entries carry a version_id sourced from the DB row (e.g., a row-level version counter or updated_at timestamp). On a write to the DB:
- The DB increments the version on the row.
- The write path sends an invalidation message (or updates the cache directly) with the new version.
- On next read, the cache compares its stored version against the expected version. A version mismatch triggers a synchronous reload.
This gives fine-grained invalidation without a blanket TTL-based expiry.
Consistent Hashing Across Cache Nodes
In a distributed read-through cache cluster, consistent hashing ensures that a given key always maps to the same cache node. This is critical for read-through correctness: if different nodes could serve the same key, they would each maintain separate loader state and potentially issue redundant DB queries. With consistent hashing, a cache miss is always handled by the same authoritative node for that key.
Background Pre-Fetch
A pre-fetch job scans the cache for entries approaching their TTL expiry and proactively refreshes them before they expire. This eliminates the latency spike that would occur if many entries expired simultaneously (thundering herd). Pre-fetch candidates are identified by querying access frequency — only frequently accessed entries are worth the pre-fetch cost.
SQL Schema
CREATE TABLE CacheEntry (
cache_key TEXT PRIMARY KEY,
value JSONB NOT NULL,
version_id BIGINT,
loaded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX idx_cache_expiry ON CacheEntry (expires_at);
CREATE TABLE CacheLoader (
key_pattern TEXT PRIMARY KEY,
loader_type TEXT NOT NULL,
ttl_seconds INT NOT NULL,
stale_ttl_seconds INT NOT NULL
);
Python Implementation Sketch
import time, threading
from typing import Callable, Optional
class ReadThroughCache:
def __init__(self, db):
self.db = db
self.store: dict[str, dict] = {}
self.loaders: dict[str, Callable] = {}
self.lock = threading.Lock()
def register_loader(self, key_pattern: str, loader_fn: Callable, ttl: int, stale_ttl: int):
self.loaders[key_pattern] = {'fn': loader_fn, 'ttl': ttl, 'stale_ttl': stale_ttl}
def get(self, key: str) -> Optional[dict]:
with self.lock:
entry = self.store.get(key)
now = time.time()
if entry:
if now < entry['expires_at']:
return entry['value']
elif now Optional[dict]:
loader_cfg = self._find_loader(key)
if not loader_cfg:
return None
value = loader_cfg['fn'](key)
if value is None:
return None
now = time.time()
entry = {
'value': value,
'expires_at': now + loader_cfg['ttl'],
'stale_expires_at': now + loader_cfg['ttl'] + loader_cfg['stale_ttl'],
}
with self.lock:
self.store[key] = entry
return value
def stale_while_revalidate(self, key: str):
self.load_from_db(key)
def invalidate_version(self, key: str, new_version: int):
with self.lock:
entry = self.store.get(key)
if entry and entry.get('version_id', -1) < new_version:
del self.store[key]
def _find_loader(self, key: str):
import fnmatch
for pattern, cfg in self.loaders.items():
if fnmatch.fnmatch(key, pattern):
return cfg
return None
Consistency with Write Operations
Read-through caches must be paired with a consistent write strategy. Options:
- Write-through: update cache and DB synchronously on every write — cache always current.
- Write-invalidate: on write, delete the cache entry; next read reloads from DB via the loader.
- Version-based invalidation: as described above, propagate new version to trigger stale detection.
See also: Scale AI Interview Guide 2026: Data Infrastructure, RLHF Pipelines, and ML Engineering
See also: Meta Interview Guide 2026: Facebook, Instagram, WhatsApp Engineering
See also: Stripe Interview Guide 2026: Process, Bug Bash Round, and Payment Systems
See also: Anthropic Interview Guide 2026: Process, Questions, and AI Safety