Or: How I Learned to Stop Worrying and Love the Hit Ratio
Remember the old saying? "There are only two hard things in Computer Science: cache invalidation and naming things." Everyone laughs. Nobody fixes it.
I once reviewed a cost audit of a $12,000/month Redis cluster caching user profiles. The hit ratio was 85%. Sounds impressive until you do the math: a cache miss on a simple indexed PostgreSQL query costs 0.5ms (Redis lookup) + 0.8ms (database query) = 1.3ms. Without caching, it's just 0.8ms directly to the database. The cache added 0.5ms overhead on every request. At 1000 requests/second with 85% hits, that's 150 misses/second × 0.5ms = 75ms/second of wasted latency. The $12,000/month infrastructure was saving < 2ms on the p95 latency. The infrastructure cost per millisecond saved: $6,000.
Like Total Recall—you think you're getting into your dreams, but you're just paying for the experience. The engineer managing that cache? Still checking hit ratios at 3 AM, wondering why everything feels slow.
This is a guide for people who actually have to live in production. Not theory. Not benchmarks from tech talks. Real numbers, real tradeoffs, and honest accounting of whether the cache is actually worth it.
The Fundamental Problem
Caching is seductive. It promises speed. It promises reduced database load. It promises that your weekend won't be interrupted by alerts. What it actually does is shift complexity around—it doesn't remove it.
The real question isn't "should we cache?" It's "what are we caching, for how long, for whom, and what breaks when the cache lies?"
Get that wrong, and you've built a system that's faster at serving stale data than your unoptimized alternative. You're trying to escape the matrix, but you're still in it—just a more expensive version.
Tier 1: In-Memory Cache (The Simple Lie)
Let's start with the seductive simplicity: a Python dict with lazy expiration on access.
import time
from typing import Any, Callable, Optional, TypeVar
T = TypeVar('T')
class SimpleMemoryCache:
"""
Single-worker in-memory cache with lazy expiration.
This is genuinely useful when you want isolated caching
per worker and don't care about global state. It's also
dangerously simple.
"""
def __init__(self):
self._cache: dict[str, tuple[Any, float]] = {}
def get(self, key: str) -> Optional[Any]:
if key not in self._cache:
return None
value, expiry = self._cache[key]
# Lazy expiration: only check when accessed
if time.time() > expiry:
del self._cache[key]
return None
return value
def set(self, key: str, value: Any, ttl_seconds: int = 300):
"""Cache a value with TTL in seconds."""
self._cache[key] = (value, time.time() + ttl_seconds)
def delete(self, key: str):
self._cache.pop(key, None)
# As a decorator
_simple_cache = SimpleMemoryCache()
def simple_cache(ttl_seconds: int = 300):
def decorator(func: Callable[..., T]) -> Callable[..., T]:
def wrapper(*args, **kwargs) -> T:
# Warning: This assumes args/kwargs are hashable
# Real world? Prepare for pain with unhashable types
try:
cache_key = f"{func.__name__}:{args}:{kwargs}"
except TypeError:
# Unhashable argument, just call the function
return func(*args, **kwargs)
cached = _simple_cache.get(cache_key)
if cached is not None:
return cached
result = func(*args, **kwargs)
_simple_cache.set(cache_key, result, ttl_seconds)
return result
return wrapper
return decorator
# Usage
@simple_cache(ttl_seconds=60)
def get_user_profile(user_id: int):
# Simulating expensive operation
time.sleep(2)
return {"user_id": user_id, "name": "Alice"}
print(get_user_profile(1)) # 2 seconds
print(get_user_profile(1)) # ~0ms (cached)
When this works:
- Single-threaded services where each request is independent
- Microservice where caching locally reduces external API calls
- Distributed systems where you want each worker to have its own cache and don't care about consistency
- Cheap data: if a cache miss just triggers a DB query, who cares?
When this breaks spectacularly:
- Multi-threaded code. Race conditions everywhere. The dict is thread-safe for reads, but your logic isn't.
- Web servers with multiple worker processes (gunicorn, uvicorn with multiple workers). Each process has its own dict. No sharing. High miss ratio. You just added overhead.
- Memory leaks. Lazy expiration means dead keys stay in memory until someone asks for them. If you have 50,000 unique cache keys and access only 10,000 daily, you're bleeding memory.
- You'll have engineers arguing about TTL values. They want 1 hour. You know it should be 5 minutes. Nobody wins.
The honest truth: This pattern is useful for protecting against the same request coming in twice within milliseconds. For anything else, it's a trap. Like Ghostbusters crossing the streams—sometimes your simple solution creates chaos when you scale it.
Tier 1.5: The Lambda Exception (Simple Dict with 15-Minute Boundary)
There's one specific case where simple dict caching is not just acceptable—it's the right choice: AWS Lambdas.
Lambdas are ephemeral. A Lambda function lives for 15 minutes (default, configurable). The runtime persists across invocations during that lifecycle window. Within that window, a simple in-memory dict is perfect: no network latency, no SDK overhead, no Redis dependency.
# Lambda-specific caching strategy
import json
from time import time
from typing import Any, Optional, Callable, TypeVar
T = TypeVar('T')
class LambdaMemoryCache:
"""
Cache for Lambda functions.
- Lives as long as the Lambda execution environment lives (15 min default)
- No external dependencies (no Redis SDK to import and initialize)
- Trades memory for latency and simplicity
- When Lambda is recycled, cache is gone (intentional)
"""
def __init__(self):
self._cache: dict[str, tuple[Any, float]] = {}
def get(self, key: str) -> Optional[Any]:
if key not in self._cache:
return None
value, expiry = self._cache[key]
if time() > expiry:
del self._cache[key]
return None
return value
def set(self, key: str, value: Any, ttl_seconds: int = 900): # Default 15 min
self._cache[key] = (value, time() + ttl_seconds)
# Singleton for the Lambda lifecycle
_lambda_cache = LambdaMemoryCache()
def lambda_cache(ttl_seconds: int = 900):
"""Decorator for Lambda-friendly caching."""
def decorator(func: Callable[..., T]) -> Callable[..., T]:
def wrapper(*args, **kwargs) -> T:
# For Lambdas, args are usually simple (user_id, config key, etc.)
try:
cache_key = f"{func.__name__}:{args}:{sorted(kwargs.items())}"
except TypeError:
return func(*args, **kwargs)
cached = _lambda_cache.get(cache_key)
if cached is not None:
return cached
result = func(*args, **kwargs)
_lambda_cache.set(cache_key, result, ttl_seconds)
return result
return wrapper
return decorator
# Real-world Lambda example
@lambda_cache(ttl_seconds=900)
def fetch_dynamodb_config(environment: str):
"""
Within a 15-minute Lambda lifecycle, if the same environment
config is requested multiple times, we get it from the dict.
Without caching: every invocation pays network cost to DynamoDB.
With caching: subsequent calls in the same execution context are free.
"""
import boto3
dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table('config')
response = table.get_item(Key={'environment': environment})
return response.get('Item', {})
def lambda_handler(event, context):
"""
If this Lambda is invoked 5 times in 15 minutes with the same environment,
it caches the config fetch 4 times.
"""
env = event.get('environment', 'dev')
config = fetch_dynamodb_config(env)
# More processing...
return {'statusCode': 200, 'body': json.dumps(config)}
Why this makes sense:
- No external dependency. Redis client library isn't loaded. No network calls to Redis. Startup time: milliseconds.
- Bounded lifetime. The cache dies when the Lambda dies. No memory management issues.
- True intra-process caching. Multiple invocations of the same Lambda can reuse the same execution context. AWS keeps Lambdas warm for cost reasons—you benefit.
- Low latency. A dict lookup is microseconds. Redis is milliseconds.
The catch:
- Lottery-based reuse. You don't control whether AWS reuses your execution environment. AWS might recycle it after 1 invocation or 100. You can't rely on it.
- Memory limits. Lambda has 128MB-10GB memory limit. Cache too much, and you hit the limit.
- No sharing across Lambda instances. 10 concurrent Lambda invocations? 10 separate caches. No cross-instance coherence.
When to use it:
- Expensive initialization (DB connections, API credentials): Cache once, reuse across invocations
- Frequently-accessed reference data within a single request cycle
- Avoiding redundant external API calls within the 15-minute window
- You want to skip the operational overhead of Redis
When to not use it:
- If you need consistency across instances (use DynamoDB or ElastiCache instead)
- If you need persistence beyond 15 minutes
- If the memory footprint is significant
The honest play: This is the "good enough" cache for Lambda workloads. It's not sophisticated. It's not guaranteed. But it's simple, has zero operational overhead, and works for the constraints of Lambda.
The Exception: Pandas DataFrame Caching
Here's where simple dict caching actually shines: expensive pandas operations with short TTLs.
If you're querying a dataset, doing transformations, and that same result gets requested multiple times within a 15-minute window, a simple in-memory cache avoids the entire Redis ecosystem.
import pandas as pd
from datetime import datetime, timedelta
from typing import Optional
class PandasCache:
"""
Simple cache specifically for pandas DataFrames.
Use case: Expensive query results that get re-requested
within 15 minutes. Single worker, bounded data.
Avoids Redis SDK overhead, network latency, and operational complexity.
"""
def __init__(self):
self._cache: dict[str, tuple[pd.DataFrame, float]] = {}
self.default_ttl = 900 # 15 minutes
def get(self, key: str) -> Optional[pd.DataFrame]:
if key not in self._cache:
return None
df, expiry = self._cache[key]
if datetime.now().timestamp() > expiry:
del self._cache[key]
return None
return df.copy() # Return copy to avoid mutations
def set(self, key: str, df: pd.DataFrame, ttl_seconds: int = 900):
"""Cache a DataFrame with TTL."""
self._cache[key] = (df.copy(), datetime.now().timestamp() + ttl_seconds)
def memory_usage(self) -> dict:
"""Track what you're actually storing."""
total_bytes = 0
for key, (df, _) in self._cache.items():
total_bytes += df.memory_usage(deep=True).sum()
return {
"total_mb": total_bytes / (1024 * 1024),
"cached_dataframes": len(self._cache)
}
# Usage example
pandas_cache = PandasCache()
def get_sales_report(year: int, region: str) -> pd.DataFrame:
"""
Expensive query + transformation.
If the same (year, region) is requested 10 times in 5 minutes,
we hit the cache 9 times instead of the database 10 times.
No Redis. No SDK. No deployment complexity.
"""
cache_key = f"sales_report:{year}:{region}"
cached = pandas_cache.get(cache_key)
if cached is not None:
return cached
# Expensive operation
df = pd.read_sql(
f"SELECT * FROM sales WHERE year={year} AND region='{region}'",
connection
)
# Transformations that take time
df['revenue'] = df['quantity'] * df['price']
df['month'] = pd.to_datetime(df['date']).dt.month
# Cache and return
pandas_cache.set(cache_key, df)
return df.copy()
# Monitor what's cached
print(pandas_cache.memory_usage())
# Output: {'total_mb': 45.2, 'cached_dataframes': 3}
When this pattern wins:
- Data scientists or analysts running interactive queries against the same dataset repeatedly
- Dashboard endpoints that get hit by multiple users with the same parameters
- Report generation where the same filters produce identical results
- Single-worker FastAPI/Flask service where each process can have its own cache
- You have 50MB of data, not 50GB
Why avoid Redis for this:
- Redis SDK introduces a dependency
- Network round-trip (even localhost) adds 2-5ms per request
- Memory overhead in Redis (50+ bytes per key)
- Operational: another service to run, monitor, and troubleshoot
- For a 30MB DataFrame refreshing every 15 minutes in a single worker, Redis is theater
The hard constraint: This only works if:
- Single worker (or workers don't share cache and that's acceptable)
- Data changes are okay at a 15-minute granularity
- Total cached data stays under your available memory (not shared across processes)
- You monitor memory usage and have alerts
The honest limitation: If three workers all cache the same 30MB DataFrame independently, you're using 90MB instead of 30MB. This is fine for small datasets. At scale, it becomes wasteful—that's when Redis makes sense.
But for a single-worker analytics service? This is the pragmatic choice. Simple, zero operational overhead, no SDK, and measurably faster.
Tier 2: LRU Cache (Bounded Stupidity)
LRU (Least Recently Used) cache addresses the memory problem. You set a max size. When it fills up, the oldest unused item gets evicted.
Python's functools.lru_cache is the standard solution:
from functools import lru_cache
@lru_cache(maxsize=128)
def expensive_computation(n: int) -> int:
"""
Bounded in-memory cache with LRU eviction.
functools.lru_cache is thread-safe and gives you metrics.
It's also a trap if you don't understand its limitations.
"""
# Some expensive operation
return n * n
# Check what's happening
print(expensive_computation.cache_info())
# Output: CacheInfo(hits=0, misses=1, maxsize=128, currsize=1)
# Force clear if needed
expensive_computation.cache_clear()
If you need more control, build your own:
from collections import OrderedDict
import threading
class LRUCache:
"""
Simple LRU cache that actually works for single-threaded code.
Multi-threaded? You'll need locks. And now you've got contention.
"""
def __init__(self, max_size: int = 128):
self.cache: OrderedDict[str, Any] = OrderedDict()
self.max_size = max_size
self.lock = threading.RLock()
def get(self, key: str) -> Optional[Any]:
with self.lock:
if key not in self.cache:
return None
# Move to end (most recently used)
self.cache.move_to_end(key)
return self.cache[key]
def set(self, key: str, value: Any):
with self.lock:
if key in self.cache:
self.cache.move_to_end(key)
self.cache[key] = value
else:
self.cache[key] = value
if len(self.cache) > self.max_size:
# Remove least recently used
self.cache.popitem(last=False)
# Usage
cache = LRUCache(max_size=50)
cache.set("key1", "value1")
value = cache.get("key1")
When LRU makes sense:
- Computing the same value multiple times in a request cycle
- Expensive transformations (parsing, validation)
- You have bounded, known data (like "we'll never cache more than 100 users")
- You genuinely have memory pressure and need controlled eviction
When people use it wrong:
- Caching unbounded data sets. Your "max_size=1000" becomes "evict constantly and hit CPU on evictions"
- Cache stampede: 10 requests come in for the same expired key at the same time. You compute it 10 times.
- Thread contention: That lock you added? Now every request waits. The cache becomes a bottleneck.
- Stale data serving as your SLA. "99% of requests got cached data within 30ms" (it was 6 hours old, but sure)
The gotcha nobody mentions: functools.lru_cache requires arguments to be hashable. Pass a dict or a list, and it breaks silently (well, loudly, but in production it's silent-loud).
@lru_cache(maxsize=128)
def process_data(data: dict) -> str:
# This will raise TypeError: unhashable type: 'dict'
return str(data)
Tier 3: Distributed Cache (The "Let's Use Redis" Dream)
Now we're talking about real distributed systems. Multiple workers, multiple processes, one source of truth.
Redis is the standard answer, and for good reason. It has native TTL, is absurdly fast, and does exactly one thing: store strings.
import redis
import json
from typing import Any, Optional
from functools import wraps
class RedisCache:
"""
Distributed cache that actually works across workers.
The catch: you now depend on Redis. When Redis is slow,
everything is slow. When Redis is down, you fail.
It's like Jurassic Park—you've built something complex and powerful,
and when the power goes out, everything starts hunting you.
"""
def __init__(self, redis_url: str = "redis://localhost:6379"):
self.client = redis.from_url(redis_url, decode_responses=True)
def get(self, key: str) -> Optional[Any]:
value = self.client.get(key)
if value is None:
return None
try:
return json.loads(value)
except json.JSONDecodeError:
return value
def set(self, key: str, value: Any, ttl_seconds: int = 300):
"""
Set with automatic TTL.
This is the right way: every write specifies expiration.
If you don't, your cache becomes a dumping ground.
"""
if isinstance(value, str):
self.client.setex(key, ttl_seconds, value)
else:
self.client.setex(key, ttl_seconds, json.dumps(value))
def delete(self, key: str):
self.client.delete(key)
def invalidate_pattern(self, pattern: str):
"""
Dangerous but sometimes necessary.
'user:123:*' -> invalidate all caches for user 123
"""
for key in self.client.scan_iter(match=pattern):
self.client.delete(key)
# Decorator
redis_cache = RedisCache()
def redis_cache_decorator(ttl_seconds: int = 300, key_prefix: str = "cache"):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
# This still has the hashability problem
cache_key = f"{key_prefix}:{func.__name__}:{args}:{kwargs}"
cached = redis_cache.get(cache_key)
if cached is not None:
return cached
result = func(*args, **kwargs)
redis_cache.set(cache_key, result, ttl_seconds)
return result
return wrapper
return decorator
@redis_cache_decorator(ttl_seconds=300, key_prefix="user")
def fetch_user(user_id: int):
# Expensive DB query or API call
return {"id": user_id, "name": "Alice"}
When Redis caching wins:
- Multiple services need shared data
- Data is accessed frequently (>100 req/sec) and reads are cheaper than writes
- You already have Redis (and ops team that knows how to run it)
- Consistency across workers matters
Where it fails:
- Cost. Redis memory is expensive. A single key takes 50+ bytes of overhead. Cache a million users? You're looking at 50GB+.
- Complexity. Now you have another system to monitor. Redis latency? Redis memory fragmentation? Cache coherency?
- Cache stampede. That key just expired, and 100 requests hit at the same time. They all miss, all call the database, all write back. Database melts.
- The "dumping ground" problem. Engineers cache everything. Response objects, raw API responses, computation results. Redis becomes a monster with 50GB of data nobody validates.
The honest gotcha: People treat Redis TTL as exact. It's not. Keys usually expire when you say, but:
- Redis could lag
- You set TTL=0, thinking it deletes immediately (it doesn't)
- You set TTL and never check if the data underneath changed
Tier 4: Dynamic Cache (When Data Changes)
This is where caching gets interesting. You cache, but you need to invalidate when the underlying data changes.
Pattern 1: Event-Driven Invalidation
This is the right way if you have an event system:
from dataclasses import dataclass
from typing import Callable
@dataclass
class DataChangeEvent:
"""Event published when data changes."""
resource_type: str # "user", "product", etc.
resource_id: int
action: str # "created", "updated", "deleted"
class CacheInvalidator:
"""
Listen to events and invalidate relevant cache keys.
"""
def __init__(self, cache: RedisCache):
self.cache = cache
self.handlers: dict[str, list[Callable]] = {}
def on_event(self, resource_type: str):
def decorator(func: Callable):
if resource_type not in self.handlers:
self.handlers[resource_type] = []
self.handlers[resource_type].append(func)
return func
return decorator
def handle_event(self, event: DataChangeEvent):
"""When an event comes in, invalidate relevant caches."""
handlers = self.handlers.get(event.resource_type, [])
for handler in handlers:
handler(event)
invalidator = CacheInvalidator(redis_cache)
@invalidator.on_event("user")
def invalidate_user_cache(event: DataChangeEvent):
"""When a user is updated, clear their cache."""
if event.action in ["updated", "deleted"]:
pattern = f"user:fetch_user:{event.resource_id}:*"
redis_cache.invalidate_pattern(pattern)
# When something publishes a user.updated event:
# invalidator.handle_event(DataChangeEvent("user", 123, "updated"))
When this works:
- Event system already exists (message queue, pub/sub, event log)
- Data changes are infrequent relative to reads (10:1 or higher)
- Invalidation latency matters (<100ms)
- You have observability to track what was invalidated
When people fail at this:
- "We have an event system" but half the code path doesn't publish events
- The event queue has 10-minute lag. Your cache expires in 5 minutes. You served stale data anyway.
- Invalidation is based on assumptions about data relationships that change over time
Pattern 2: TTL + Probabilistic Revalidation
Instead of events, you bet on TTL being "good enough" most of the time:
import random
class SmartCache:
"""
Cache with TTL, but periodically revalidate expensive data
instead of serving stale data.
This requires having a revalidation function available.
"""
def __init__(self, cache: RedisCache):
self.cache = cache
def get_or_compute(
self,
key: str,
compute_fn: Callable[[], Any],
ttl_seconds: int = 300,
revalidation_probability: float = 0.1
) -> Any:
"""
Get from cache, but with probabilistic revalidation.
1% of the time (tunable), ignore the cache and recompute,
then update. This prevents the "cache stampede" where
everyone hits the backend at once when TTL expires.
"""
cached = self.cache.get(key)
if cached is not None:
# Small chance to revalidate without serving stale data
if random.random() < revalidation_probability:
# Spawn background task to recompute
# (simplified here, real code uses tasks/threads)
pass
return cached
# Cache miss, compute and store
result = compute_fn()
self.cache.set(key, result, ttl_seconds)
return result
Tier 5: Database-Level Caching (The Thing You Missed)
Here's what nobody mentions: sometimes the database already caches what you're trying to cache.
When your application cache is overhead:
- Connection pooling keeps the connection warm (no handshake cost)
- The database's query cache is working
- Your indexes are good and queries are fast (<5ms)
- Network latency to the cache > latency to database + cache lookup
People cache to optimize away database queries. But if the database already optimizes the query...you're just adding latency.
Real examples: When caching the database makes sense vs. doesn't
Example 1: User profile lookup (CACHE IT)
# This makes sense to cache
@redis_cache_decorator(ttl_seconds=300)
def get_user_profile(user_id: int):
# Complex query with joins
return db.query(User)\
.join(UserSettings)\
.filter(User.id == user_id)\
.first()
# Why:
# - Query is slow: 50-100ms with cold database cache
# - Read-heavy: 1000s of requests per hour
# - Data is semi-stable: user data changes infrequently
# - TTL is safe: stale profile for 5 minutes is acceptable
# Value: (1000 × 0.075) - $0.10 = 74.90 saved per day
Example 2: Leaderboard query (DON'T CACHE IT)
# This doesn't make sense to cache
def get_leaderboard(limit: int = 100):
return db.query(Score)\
.order_by(Score.points.desc())\
.limit(limit)\
.all()
# Why not:
# - Query is fast: 10ms even with 10M rows (indexed)
# - Data is volatile: scores change constantly
# - Invalidation nightmare: every score update invalidates the cache
# - Cache hit ratio: maybe 20% before data changes
# - Redis memory cost: storing leaderboard + invalidation overhead > query cost
# Value: (1000 × 0.010) - $5/month = -$4.50/month. Don't cache.
Example 3: Product catalog (CACHE IT)
# This is the textbook case
@redis_cache_decorator(ttl_seconds=3600) # 1 hour
def get_product(product_id: int):
return db.query(Product)\
.filter(Product.id == product_id)\
.first()
# Why:
# - Read-heavy: 10,000 requests/sec for same products
# - Low-change: inventory updates via separate pipeline
# - Safe staleness: 1-hour-old price is acceptable
# - Network cost: latency to database in different region
# Value: (10000/sec × 3600 × 0.030ms) - $50/month = huge savings
Example 4: Session state (CACHE WITH CAUTION)
# This seems like caching but is actually just storage
def get_session(session_id: str):
# This isn't caching—it's the authoritative store
return redis_cache.get(f"session:{session_id}")
# Why this is tricky:
# - You're not caching the database
# - You're replacing the database with Redis
# - If Redis fails, sessions are gone
# - Better: use Redis for performance, write-through to DB for durability
Database indexing vs. caching:
# Before caching, did you check your indexes?
# CACHE:
@redis_cache_decorator(ttl_seconds=300)
def find_users_by_email(email: str):
return db.query(User).filter(User.email == email).first()
# Better: Add index first
# CREATE INDEX idx_user_email ON users(email);
# Query becomes <1ms. Caching overhead (2-5ms) makes it slower.
# Check if index exists:
# SELECT * FROM pg_indexes WHERE tablename = 'users';
# If idx_user_email is missing, add it. Don't cache to work around bad indexing.
When database-level optimization wins:
# Bad: Application-level caching
@redis_cache_decorator(ttl_seconds=300)
def get_user(user_id: int):
return db.query(User).filter(User.id == user_id).first()
# Better: Fix the database
# - Add index on User.id (probably already there)
# - Use connection pooling (pgBouncer, pgpool)
# - Query result is <5ms, caching adds 2-5ms latency overhead
# Just accept the 5ms database hit
Using SQLAlchemy with cacheops is interesting, but:
from cacheops import cached_as, invalidate_obj
# Automatic cache invalidation when ORM objects change
class User(db.Model):
id: int
name: str
@classmethod
@cached_as(timeout=300)
def get_by_id(cls, user_id: int):
return cls.query.filter(cls.id == user_id).first()
# Automatic invalidation on save
def update_user(user_id: int, name: str):
user = User.query.get(user_id)
user.name = name
db.session.commit()
invalidate_obj(user) # Invalidate related caches
The problem: This looks elegant but hides complexity. If you have 20 different queries on User, do they all get invalidated? What if you update one field that only affects one query? You're over-invalidating or under-invalidating.
Real-world rule of thumb:
Cache the query if:
- Query latency > 20ms (expensive enough to matter)
- Read frequency > 10x write frequency (reads dominate)
- Data staleness > TTL is acceptable to users
- Storage cost < savings from avoiding queries
Don't cache if:
- Query is already <5ms (network overhead > query cost)
- Write frequency is high (constant invalidation)
- Data must be consistent within seconds
- You haven't verified the query is actually slow
Practical Caching Use Cases That Actually Make Sense
Not all caching is performance optimization. Sometimes it's about correctness and resource efficiency. These are the cases where cache isn't a nice-to-have feature—it's the load-bearing wall of your architecture. Like the Alien franchise: sometimes you need something dangerous, not because it's optimal, but because it's the only thing that works.
Use Case 1: Rate Limiting (Preventing Abuse)
class RateLimiter:
"""
Cache is the enforcement mechanism, not just optimization.
"""
def __init__(self, redis_client):
self.redis = redis_client
def is_allowed(self, user_id: str, limit: int = 100, window: int = 60) -> bool:
"""
Allow 100 requests per 60 seconds.
This *requires* distributed state. You can't do this with
a local dict—too many workers.
"""
key = f"ratelimit:{user_id}"
# Increment counter and set expiry
count = self.redis.incr(key)
if count == 1:
self.redis.expire(key, window)
return count <= limit
# In your request handler:
limiter = RateLimiter(redis_client)
if not limiter.is_allowed(user_id):
return 429 # Too Many Requests
# Cost: Saves you from DDoS and runaway clients
# Value: Preventing $50,000 AWS bill on bad actor > Redis cost
Use Case 2: Redis Locking (Task Deduplication)
Here's where caching is a lifesaver: preventing duplicate work.
import uuid
import time
class DistributedLock:
"""
Use Redis as a lock mechanism to prevent duplicate processing.
Classic case: 10 workers all trying to send the same email
at the same time because they all see the task as unprocessed.
"""
def __init__(self, redis_client):
self.redis = redis_client
def acquire(
self,
lock_key: str,
ttl_seconds: int = 10
) -> bool:
"""
Try to acquire a lock.
Returns True if we got it, False if someone else has it.
Expires automatically after ttl_seconds (safety against crashes).
"""
# Set only if key doesn't exist
# This is atomic—no race conditions
lock_id = str(uuid.uuid4())
acquired = self.redis.set(
f"lock:{lock_key}",
lock_id,
nx=True, # Only set if doesn't exist
ex=ttl_seconds
)
return acquired is not None
def release(self, lock_key: str):
"""Release the lock if we still own it."""
# Don't delete blindly—ensure we own it
self.redis.delete(f"lock:{lock_key}")
# Usage: Prevent duplicate task processing
lock = DistributedLock(redis_client)
def process_email_task(email_id: int):
"""
Multiple workers might try to process the same email.
Redis lock ensures only one actually does the work.
"""
if not lock.acquire(f"email:{email_id}", ttl_seconds=30):
# Someone else is processing this email
return # Skip it
try:
# Do the expensive work
send_email(email_id)
database.update_email_status(email_id, "sent")
finally:
lock.release(f"email:{email_id}")
# What this saves:
# Without lock: Email sent 10 times (customer gets 10 copies, angry)
# With lock: Email sent once
# Cost of Redis lock: negligible
# Cost of not using lock: customer rage, refunds, lost trust
Use Case 3: Counters (Analytics)
class EventCounter:
"""
Count events with minimal overhead.
Why not count in the database?
Because incrementing a counter is faster in Redis (microseconds)
than in database (milliseconds).
"""
def __init__(self, redis_client):
self.redis = redis_client
def increment(self, event_name: str, count: int = 1):
"""Count an event in Redis."""
key = f"counter:{event_name}:{datetime.now().strftime('%Y-%m-%d')}"
self.redis.incrby(key, count)
self.redis.expire(key, 86400 * 90) # Keep 90 days
def get_count(self, event_name: str, date: str) -> int:
"""Get count for a specific date."""
key = f"counter:{event_name}:{date}"
return int(self.redis.get(key) or 0)
def flush_to_database(self):
"""
Periodically (nightly), flush counts to the database.
Trade: slightly stale analytics, huge latency improvement.
"""
pattern = "counter:*"
for key in self.redis.scan_iter(match=pattern):
event_data = key.split(":")
event_name = event_data[1]
date = event_data[2]
count = int(self.redis.get(key))
# Write to database
database.insert_event_count(event_name, date, count)
self.redis.delete(key)
# Cost:
# Database: 1000 rows/sec with full ACID = slow
# Redis: 1,000,000 increments/sec = fast
# Flushing nightly: 1 database transaction = cheap
# Value: Real-time analytics without database contention
Use Case 4: Session Storage (With Fallback)
class SessionStore:
"""
Sessions in Redis, with write-through to database for durability.
Redis dies: You lose sessions but the app keeps running.
Database dies: Sessions still work (they're in Redis).
Both die: You have a real problem, but at least you see it.
"""
def __init__(self, redis_client, db):
self.redis = redis_client
self.db = db
def create_session(self, user_id: int, data: dict) -> str:
"""Create a session."""
session_id = str(uuid.uuid4())
session_data = json.dumps({"user_id": user_id, **data})
# Write to both
self.redis.setex(f"session:{session_id}", 3600, session_data)
self.db.insert_session(session_id, user_id, session_data)
return session_id
def get_session(self, session_id: str) -> Optional[dict]:
"""Get session (fast path in Redis)."""
cached = self.redis.get(f"session:{session_id}")
if cached:
return json.loads(cached)
# Cache miss: check database
# (session might still be valid, Redis cache just expired)
db_session = self.db.get_session(session_id)
if db_session:
# Restore to Redis cache
self.redis.setex(
f"session:{session_id}",
3600,
db_session['data']
)
return json.loads(db_session['data'])
return None
Use Case 5: Feature Flags (With TTL)
class FeatureFlagCache:
"""
Cache feature flags for performance.
Reading from the database every request for feature flags
is wasteful. Cache with short TTL (30s) and control rolls.
"""
def __init__(self, redis_client, db):
self.redis = redis_client
self.db = db
def is_enabled(self, flag_name: str, user_id: int) -> bool:
"""
Check if flag is enabled for user.
Cached: 30 seconds (good compromise between freshness and latency)
"""
cache_key = f"flag:{flag_name}:{user_id}"
cached = self.redis.get(cache_key)
if cached is not None:
return cached == "true"
# Cache miss: check database
enabled = self.db.is_flag_enabled(flag_name, user_id)
self.redis.setex(cache_key, 30, "true" if enabled else "false")
return enabled
def rollout_feature(self, flag_name: str):
"""
Enable feature for everyone.
Flush the cache immediately (invalidate all {flag_name}:* keys).
"""
for key in self.redis.scan_iter(match=f"flag:{flag_name}:*"):
self.redis.delete(key)
Summary of good caching:
| Use Case | Why Cache | Storage | Lifetime | Cost |
|---|---|---|---|---|
| Rate limiting | Correctness, not performance | Small | Seconds | Low |
| Task locking | Prevent duplicates | Tiny | Minutes | Low |
| Event counters | Performance (aggregation) | Medium | Days | Medium |
| Sessions | Performance + reliability | Variable | Hours | Low-Medium |
| Feature flags | Performance (frequently checked) | Small | Minutes | Low |
| User profiles | Performance (slow queries) | Large | Hours | High |
| Product catalog | Performance + cost | Large | Hours | High |
The key difference: the first group (rate limiting, locking, counters) requires caching for correctness or resource efficiency. The second group is purely performance optimization.
Not all data should be cached the same way.
High-frequency change, high-frequency read (e.g., user session state)
- TTL: short (30-60 seconds)
- Strategy: Redis + invalidation on write
- Fallback: Accept brief staleness
Low-frequency change, high-frequency read (e.g., product catalog)
- TTL: long (1-24 hours) or event-driven invalidation
- Strategy: Redis, CDN, or even application memory
- Cost: Usually justified by the read volume
High-frequency change, low-frequency read (e.g., admin dashboard data)
- Cache: Don't. Or cache very briefly (5 seconds)
- Strategy: Compute on read, accept latency
- Cost of caching > cost of computation
Low-frequency change, low-frequency read (e.g., company info)
- Cache: Sure, why not
- TTL: Long (hours)
- Strategy: Anywhere, cost doesn't matter
The sweet spot calculation:
Value of caching = (Read frequency × Computation cost) - (Cache storage cost + Cache invalidation cost)
If Value < 0, don't cache.
Example:
- Read 100 times/day
- Computation cost: 50ms (database query)
- Cache storage: $0.01/month
- Invalidation cost: essentially free (event-driven)
- Value: (100 × 0.05) - $0.01 = 4.99 worth / month
Cache it.
Counter-example:
- Read 10 times/month (rare)
- Computation cost: 100ms
- Cache storage: $1/month (Redis memory cost for this object)
- Invalidation: complex (5 different code paths might change it)
- Value: (10/30 × 0.1) - $1 = 0.03 - $1 = -$0.97 / month
Don't cache it.
The Metrics That Matter
Here's where caching breaks. You look at the dashboard, see 95% hit ratio, and think you're winning.
You're not.
Like watching the Sixth Sense ending—you think you understand what's happening, but you're looking at a corpse. The 95% hit ratio? Dead data from three hours ago.
Metric 1: Hit Ratio (The Trap)
A 95% hit ratio on useless data is worse than a 50% hit ratio on critical data.
Hit ratio = Cache hits / (Cache hits + Cache misses)
This is meaningless without context.
What matters: Hit ratio weighted by value
class CacheMetrics:
"""
Track what actually matters.
"""
def __init__(self):
self.hits = 0
self.misses = 0
self.latency_with_cache = []
self.latency_without_cache = []
@property
def hit_ratio(self) -> float:
total = self.hits + self.misses
return self.hits / total if total > 0 else 0
@property
def actual_benefit(self) -> float:
"""
Real win: how much latency did we save?
"""
avg_with = sum(self.latency_with_cache) / len(self.latency_with_cache)
avg_without = sum(self.latency_without_cache) / len(self.latency_without_cache)
# Only cache saves money if with_cache < without_cache
return avg_without - avg_with
Metric 2: Latency Impact (The Critical Metric)
This is where caching fails silently.
def analyze_cache_latency():
"""
Compare latency with cache vs. without cache using REAL BENCHMARKED DATA.
Sources:
- Redis official docs (redis.io): 0.2-0.6ms typical latency (same datacenter)
- PostgreSQL benchmarks: indexed SELECT = 0.1-0.8ms
- Network overhead: already included in above measurements
"""
# Real-world scenario: Fetching a user profile
cache_lookup = 0.5 # ms (Redis hit)
backend_latency = 0.8 # ms (PostgreSQL indexed query)
cache_miss_total = 1.3 # ms (0.5ms Redis miss check + 0.8ms query)
direct_latency = 0.8 # ms (just database)
# Scenario 1: Good hit ratio (80%)
avg_with_cache_80 = (0.80 * cache_lookup) + (0.20 * cache_miss_total)
# = (0.80 × 0.5) + (0.20 × 1.3) = 0.4 + 0.26 = 0.66ms
# Savings: 0.8 - 0.66 = 0.14ms (17.5% improvement)
# Scenario 2: Mediocre hit ratio (50%)
avg_with_cache_50 = (0.50 * cache_lookup) + (0.50 * cache_miss_total)
# = (0.50 × 0.5) + (0.50 × 1.3) = 0.25 + 0.65 = 0.90ms
# Worse than no cache by 0.10ms
# Scenario 3: Low hit ratio (20%)
avg_with_cache_20 = (0.20 * cache_lookup) + (0.80 * cache_miss_total)
# = (0.20 × 0.5) + (0.80 × 1.3) = 0.1 + 1.04 = 1.14ms
# Worse than no cache by 0.34ms (42% slower!)
return {
"direct_latency_ms": direct_latency,
"with_cache_80_percent_hits": avg_with_cache_80,
"with_cache_50_percent_hits": avg_with_cache_50,
"with_cache_20_percent_hits": avg_with_cache_20,
"savings_at_80_percent_ms": 0.14,
"overhead_at_50_percent_ms": 0.10,
"overhead_at_20_percent_ms": 0.34
}
The break-even point for caching:
Caching only helps if:
hit_ratio × backend_latency > cache_lookup_latency
With realistic numbers:
hit_ratio × 0.8 > 0.5
hit_ratio > 0.625
You need >62.5% hit ratio just to break even.
Below that, cache makes you slower.
Real-world example: When caching is actually worthwhile
Scenario: Caching expensive external API calls
- External API response time: 200ms (slow third-party service)
- Redis cache lookup: 0.5ms
- Hit ratio: 75% (request patterns have good reuse)
- Traffic: 100 requests/second
- TTL: 300 seconds
Latency WITH cache:
(0.75 × 0.5) + (0.25 × 200.5) = 0.375 + 50.125 = 50.5ms
Latency WITHOUT cache:
200ms
Savings: 149.5ms per request (75% improvement)
Cost analysis:
- Redis infrastructure: $100/month
- Operational overhead: 1 hour/month = $150
- Total: $250/month
Value:
- 100 req/sec × 86,400 sec/day × 30 days = 259.2M requests/month
- Savings: 149.5ms × 259.2M / 1000 = 38.7M seconds = 10,750 hours saved
- At $50/hour AWS compute: $537,500 in compute savings
ROI: +$537,250/month. Cache this immediately.
Real-world counter-example: When caching costs more than it saves
Scenario: Caching database queries that are already fast
- PostgreSQL indexed query: 0.8ms
- Redis cache lookup: 0.5ms
- Hit ratio: 45% (mostly unique requests)
- Traffic: 1000 requests/second
- Infrastructure cost: $150/month
Latency WITH cache:
(0.45 × 0.5) + (0.55 × 1.3) = 0.225 + 0.715 = 0.94ms
Latency WITHOUT cache:
0.8ms
Overhead: 0.14ms per request × 1000 req/sec = 140ms/second wasted
Cost analysis:
- Redis infrastructure: $150/month
- Operational overhead: 2 hours/month = $300
- Total: $450/month
Value:
- 0.14ms overhead × 86.4M requests/month = 12.1M ms = 12,096 seconds
- At $0.024/compute-hour: $80 cost of wasted compute
- Net loss: $450 - $80 = -$370/month
Decision: Remove the cache. The overhead exceeds any benefit.
**How to measure if cache is actually helping:**def audit_cache_performance():
"""
Simple measurement to tell if caching is worth it.
Measure for 1 week in production with actual traffic patterns.
"""
import time
class CacheAudit:
def __init__(self):
self.hits = 0
self.misses = 0
self.hit_latencies = []
self.miss_latencies = []
self.direct_latencies = [] # For comparison
def record_hit(self, latency_ms: float):
self.hits += 1
self.hit_latencies.append(latency_ms)
def record_miss(self, latency_ms: float):
self.misses += 1
self.miss_latencies.append(latency_ms)
def record_direct(self, latency_ms: float):
"""Latency when bypassing cache (for comparison)."""
self.direct_latencies.append(latency_ms)
def get_report(self):
total = self.hits + self.misses
hit_ratio = self.hits / total if total > 0 else 0
avg_with_cache = (
(hit_ratio * (sum(self.hit_latencies) / len(self.hit_latencies)))
+ ((1 - hit_ratio) * (sum(self.miss_latencies) / len(self.miss_latencies)))
)
avg_direct = sum(self.direct_latencies) / len(self.direct_latencies)
savings_per_request = avg_direct - avg_with_cache
print(f"Hit ratio: {hit_ratio:.1%}")
print(f"Avg latency WITH cache: {avg_with_cache:.2f}ms")
print(f"Avg latency WITHOUT cache: {avg_direct:.2f}ms")
print(f"Savings per request: {savings_per_request:.2f}ms")
# Decision logic
if hit_ratio < 0.50:
print("❌ Hit ratio < 50%: Cache is likely adding overhead")
return False
if savings_per_request < 0.05: # Less than 0.05ms savings
print("❌ Savings < 0.05ms: Not worth the infrastructure cost")
return False
print("✓ Cache appears worthwhile. Keep it.")
return True
# Usage during production monitoring
audit = CacheAudit()
# Record metrics for 1 week...
# audit.record_hit(0.5)
# audit.record_miss(1.3)
# audit.record_direct(0.8)
# audit.get_report()
Decision tree:
Does the cache save more than 0.1ms per request?
YES → Is the hit ratio > 50%?
YES → Keep the cache
NO → Remove it or redesign
NO → Remove it immediately
Metric 3: Cardinality Explosion and Storage Costs
This is insidious. You cache "user:{user_id}:profile" and you're fine. Then you add "user:{user_id}:settings", "user:{user_id}:preferences", "user:{user_id}:avatar_url". Now you have 10 million keys instead of 1 million.
Redis memory cost breakdown:
Real-world: How much does Redis memory actually cost?
Per-key overhead:
- Key: "user:123456:profile" = 20 bytes
- Value: JSON profile = 500 bytes
- Redis internal pointers: ~100 bytes
- Hash table overhead: ~50 bytes
- Total per key: ~670 bytes
Scale it:
- 1 million keys: ~670MB ≈ $30/month
- 10 million keys: ~6.7GB ≈ $300/month
- 100 million keys: ~67GB ≈ $3000/month
But that's just storage. Redis also has:
- Network overhead (Redis cluster replication)
- CPU overhead (key lookups, expiration)
- Operational cost (backups, monitoring, failover)
Real cost per key: ~0.3-1 cent per million keys per month.
Example cardinality explosion:
Started with:
- user:123:data (1 million keys, $30/month)
Then added:
- user:123:settings (1 million keys, $30/month)
- user:123:preferences (1 million keys, $30/month)
- user:123:posts (10 million keys, $300/month)
- user:123:comments (10 million keys, $300/month)
- user:123:likes (10 million keys, $300/month)
Total: 33 million keys, $990/month in Redis costs alone.
vs. if you'd just hit the database:
- Smart indexing: <5ms per query
- Connection pooling: managed costs
- Database storage: included in your instance cost
- Total: Already budgeted infrastructure cost
Track cardinality obsessively:
def check_redis_cardinality():
"""Alert if we're growing unbounded."""
info = redis_cache.client.info()
key_count = info['db0']['keys']
memory = info['used_memory']
bytes_per_key = memory / key_count if key_count > 0 else 0
# Thresholds
if key_count > 50_000_000:
alert(f"Critical: {key_count:,} keys, {memory/1e9:.1f}GB")
elif key_count > 10_000_000:
alert(f"Warning: {key_count:,} keys, {memory/1e9:.1f}GB")
# Bytes per key should be reasonable
if bytes_per_key > 200:
alert(f"High memory per key: {bytes_per_key:.0f} bytes")
# Normal range: 50-150 bytes depending on value size
Redis Atomicity: Why Lua Scripts Matter
Here's a common bug: you check a Redis value, make a decision, then write back. But between the read and write, another process changed the value.
The problem: Race conditions without atomicity
# WRONG: Multiple operations = multiple round trips
def increment_with_check(key: str, max_value: int = 100):
"""Increment a counter, but don't exceed max_value."""
# Read
current = int(redis_client.get(key) or 0)
# Logic
if current < max_value:
# Write
redis_client.incr(key)
return True
return False
# Race condition:
# Thread 1: Get key = 99
# Thread 2: Get key = 99 ← Problem! Both see 99
# Thread 1: Incr to 100
# Thread 2: Incr to 100 ← Should have been rejected!
# Result: Counter is 100, but you incremented twice over the limit
The solution: Lua scripts for atomic operations
Lua scripts run on the Redis server, atomically. No race conditions.
# Define the Lua script
increment_script = redis_client.register_script("""
local current = tonumber(redis.call('GET', KEYS[1]) or 0)
local max_val = tonumber(ARGV[1])
if current < max_val then
redis.call('INCR', KEYS[1])
return 1 -- Success
else
return 0 -- Failed, would exceed max
end
""")
# Use it
result = increment_script(keys=['counter'], args=[100])
# Returns 1 if successful, 0 if hit limit
# Guaranteed atomic: no race conditions
More realistic example: Distributed counter with threshold
class SafeDistributedCounter:
"""
Count something (events, tasks, etc.) with a threshold.
Example: Count failed login attempts. After 5, lock the account.
"""
def __init__(self, redis_client):
self.redis = redis_client
# Lua script to increment and check threshold atomically
self.increment_script = redis_client.register_script("""
local key = KEYS[1]
local threshold = tonumber(ARGV[1])
local ttl = tonumber(ARGV[2])
local current = tonumber(redis.call('GET', key) or 0)
-- Increment
local new_value = redis.call('INCR', key)
-- Set TTL if this is the first increment
if new_value == 1 then
redis.call('EXPIRE', key, ttl)
end
-- Return: new value and whether it exceeded threshold
if new_value >= threshold then
return {new_value, 1} -- {count, threshold_exceeded}
else
return {new_value, 0}
end
""")
def increment_and_check(
self,
counter_key: str,
threshold: int,
ttl_seconds: int = 300
) -> tuple[int, bool]:
"""
Increment counter and return (count, did_exceed_threshold).
All atomic—guaranteed no race conditions.
"""
result = self.increment_script(
keys=[counter_key],
args=[threshold, ttl_seconds]
)
count, exceeded = result
return count, exceeded == 1
# Usage: Lockout after 5 failed attempts
counter = SafeDistributedCounter(redis_client)
def check_login(user_id: int, password: str) -> bool:
attempts, locked = counter.increment_and_check(
f"login_attempts:{user_id}",
threshold=5,
ttl_seconds=300
)
if locked:
# Account is locked for 5 minutes
return False, f"Account locked. {5 - attempts} attempts remaining."
if authenticate_user(user_id, password):
# Reset counter on successful login
redis_client.delete(f"login_attempts:{user_id}")
return True, "Login successful"
return False, f"Invalid password. {5 - attempts} attempts remaining."
When to use Lua scripts:
- When you need to read-check-write atomically
- When you need multiple operations to succeed or fail together
- When race conditions would cause bugs
- When you're implementing distributed algorithms (locks, counters, rate limiting)
When NOT to use Lua scripts:
- Simple get/set (doesn't need atomicity)
- When the script is complex (>50 lines, hard to debug)
- When you need conditional branching based on external data (fetch from DB)
Common Lua patterns:
# Pattern 1: Compare and swap (CAS)
cas_script = redis_client.register_script("""
if redis.call('GET', KEYS[1]) == ARGV[1] then
redis.call('SET', KEYS[1], ARGV[2])
return 1
else
return 0
end
""")
# Pattern 2: Decrement if > 0 (tokens, credits)
tokens_script = redis_client.register_script("""
local current = tonumber(redis.call('GET', KEYS[1]) or 0)
if current > 0 then
redis.call('DECR', KEYS[1])
return 1
else
return 0
end
""")
# Pattern 3: Atomic push to list with size limit
list_script = redis_client.register_script("""
local key = KEYS[1]
local max_size = tonumber(ARGV[1])
local value = ARGV[2]
redis.call('LPUSH', key, value)
local size = redis.call('LLEN', key)
if size > max_size then
redis.call('RPOP', key)
end
return size
""")
Performance comparison:
Without Lua (multiple commands):
- GET: 0.5ms
- Check: 0.1ms
- SET: 0.5ms
- Total: ~1.1ms
- Risk: Race condition between GET and SET
With Lua (single atomic command):
- EVAL: ~1.0ms (slightly faster, no round trips)
- Risk: None (atomic on Redis side)
Bonus: Eliminates network overhead of multiple round trips.
At high throughput (10,000 req/sec), the difference is significant.
After 20+ years watching caching fail in production, here's what I've seen work:
- Cache only expensive, infrequently-changing data
- If it's cheap to compute, don't cache it
- If it changes constantly, don't cache it
- Choose the cache tier that matches your problem
- Single worker? In-memory dict
- Multiple workers, bounded data? LRU
- Multiple workers, unbounded data? Redis with cardinality limits
- Data changes frequently? Event-driven invalidation + short TTL as safety net
- Measure the actual benefit
- Not hit ratio. Latency improvement.
- Not feature flags. Cost impact.
- Have an escape hatch
- When caching fails, be able to disable it instantly
- Feature flag that routes percentage of traffic around cache
- Make cache failures non-catastrophic (stale data is better than no data)
- Accept that caches lie
- Eventually, inconsistency will happen
- Your system should tolerate 5-minute-old data
- If you can't tolerate stale data, don't cache it
Bonus: Caching LLM Responses (The New Frontier)
With LLM costs being real, people are asking: can we cache LLM replies?
The tempting answer: "Query the embedding model on the question, cluster semantically similar questions, return cached answers."
The honest answer: "Sometimes, but usually not for the reason you think."
The Theory vs. The Reality
The idea is sound on paper:
- User asks "What is the capital of France?"
- LLM response is cached
- User asks "What's the capital of France?"
- Same query, cached answer, saved tokens
But queries are rarely identical. So the next thought: embed questions, find similar ones, serve cached answers.
This feels like you're hacking a Terminator 2 time-jump—all this complex infrastructure to go back and stop the problem before it starts. Except you end up with the skeleton of a solution that costs more than just paying the T-1000 upfront.
When semantic similarity caching doesn't make financial sense:
Cost structure:
- Embedding model API: $0.02 per 1000 embeddings
- Vector database: $50-200/month (Pinecone, Weaviate, Qdrant)
- Search latency: 50-100ms per query
vs.
LLM input cost:
- GPT-4 Turbo: $0.01 per 1000 input tokens
- Average question: 20 tokens = $0.0002 per call
Math:
- Cache 10,000 questions: $0.20 in embedding cost
- Vector DB: $100/month
- If you cache 90% of requests, you save $0.00018 per request
- Break-even: Never. You're losing $100/month forever.
The real trap: you're betting that embedding similarity = semantic equivalence. It doesn't.
# These are semantically similar according to embeddings:
question_1 = "What is the capital of France?"
question_2 = "What city is the capital of France?"
# But the user might want different response styles:
# Q1: Quick fact → "Paris"
# Q2: Conversational → "Paris is the capital and largest city..."
# Cache serves the same answer to both. One user is annoyed.
When semantic caching MIGHT be worth the money: Speed > Cost
There's exactly one scenario where this makes sense: when latency is a SLA, and every millisecond costs you money.
Scenario: Customer support chatbot
- Response time SLA: <500ms
- Current: 200ms LLM latency + network overhead = 250ms (tight but doable)
- Load: 10,000 requests/day
- Token cost savings: 90% cache hit = $50/day saved
- Infrastructure cost: $200/month
But if you miss the SLA once:
- Customer escalation: 1 hour work = $150
- Reputation damage: small
- One missed SLA = $150 cost
If semantic caching gets you 50ms faster (150ms total):
- Margin to SLA: much better
- Risk of missing SLA: drops from 10% to 1%
- Expected value of risk reduction: 0.09 × $150 = $13.50/day
Over 30 days: $405 in risk reduction vs. $200 cost = net positive.
Decision: If speed is contractually critical, the infrastructure might be worth it.
If it's just "nice to have," it's not.
The hidden costs you're missing:
- Invalidation nightmare. When the underlying data changes, which cached answers are stale? Which semantically similar questions depend on it? Your cache becomes a consistency nightmare.
- Hallucination amplification. The LLM hallucinated once. You cached it. Now 90% of similar questions get served the same hallucination. You've scaled the problem.
- Version drift. Your LLM updates (new model, new prompt). Old cached answers are suboptimal. Do you flush? Gradually? How?
- Observability death. When something goes wrong, was it the LLM, the cache, or the embeddings? You've added three layers of potential failure.
The solution that actually works: Exact-match caching
Forget semantic similarity. Just cache identical questions.
class ExactMatchLLMCache:
"""
The boring solution that doesn't fail.
Same question in the same session? Serve cached answer.
Different question? Ask the LLM.
No embeddings, no vector DB, no hallucination scaling.
"""
def __init__(self, redis_client):
self.redis = redis_client
def get_cached_or_ask_llm(
self,
question: str,
llm_fn,
ttl_seconds: int = 300 # 5 minutes
) -> str:
# Hash the exact question
cache_key = f"llm:question:{hash(question)}"
# Check if we've seen this exact question recently
cached = self.redis.get(cache_key)
if cached:
return cached
# Ask the LLM
answer = llm_fn(question)
# Cache with short TTL
self.redis.setex(cache_key, ttl_seconds, answer)
return answer
Why this works:
- Zero complexity beyond Redis
- Protects against the same user asking the same question in the same session (happens more than you think)
- Saves maybe 5-10% of calls, not 90% (realistic)
- Invalidation is automatic via TTL
- No embedding infrastructure
- Cost: negligible
The real solution for LLM efficiency:
Stop trying to cache. Instead:
- Optimize prompts. Better engineering reduces tokens. Fewer tokens = cheaper.
- Use a smaller model. Claude Sonnet is cheaper than Opus. For FAQ, use a smaller LLM entirely.
- Batch requests. Semantic caching looks good on paper. Batching actually works in practice.
- Pre-generate answers. For known FAQs, generate the answer once (expensive compute) and serve it (free) via simple pattern matching. No caching layer, no complexity.
The honest verdict:
Semantic LLM caching is the LLM equivalent of "I'll just add Redis to fix this." It looks like a technical solution to a business problem (cost). The real problem is usually that you're using an expensive model for a cheap problem.
Like something from Robocop—all this hardware to solve what could have been handled by a cheaper solution from the start.
Caching is one of the easiest ways to take a simple system and make it complex while barely improving anything. I've seen systems where the "optimization" added 3 services, 40 lines of configuration, 2 monitoring dashboards, and saved 2ms on the p95 latency.
That 2ms? Not worth it.
The engineers trying to support it in production? They disagree, at 3 AM, on a Sunday. They're debugging which layer the problem is in—application cache, Redis, database query cache, or the poor bastard who set TTL=0 thinking it would delete immediately.
Like an '80s action movie—the hero keeps running from one problem to the next, except the problem is the hero. Predator, except the predator is your caching layer hunting you through the logs.
Cache when it matters. Measure what actually matters. Be honest about whether it's working.
And maybe, just maybe, your system doesn't need another layer.
Member discussion: