Caching Best Practices for Distributed Applications
Distributed applications depend on caching to achieve low latency, high throughput, and reasonable infrastructure costs. Without effective caching, every request competes for database connections, network bandwidth, and compute resources. As traffic grows, the database often becomes the first scalability bottleneck.
However, adding a cache does not automatically improve scalability. Poor cache key design, ineffective invalidation, inconsistent expiration policies, and lack of observability frequently create production problems that are harder to diagnose than database bottlenecks. Successful distributed systems treat caching as part of the overall architecture rather than an isolated optimization.
Table of Contents
- Core Caching Principles
- Production Best Practices
- Common Design Choices
- Production Python Examples
- Production Design Example
- Common Mistakes
- Production Checklist
- Conclusion
- More Articles to Read
Core Caching Principles
Caching should reduce work, not duplicate complexity.
Every cache layer must have a clearly defined responsibility. Browser caches reduce network traffic, CDNs eliminate origin requests, local caches reduce Redis traffic, and distributed caches reduce database load.
User
|
Browser
|
CDN
|
Reverse Proxy
|
Application
|
Local Cache
|
Redis
|
Database
Each additional layer should eliminate work performed by the layers below it.
| Layer | Primary Goal |
|---|---|
| Browser | Remove network requests |
| CDN | Reduce origin traffic |
| Reverse Proxy | Protect application servers |
| Local Cache | Reduce Redis traffic |
| Redis | Reduce database queries |
The authoritative source of data should always remain outside every cache.
Production Best Practices
Most production outages related to caching are caused by architecture decisions rather than cache technology. The following practices improve scalability while keeping cache behavior predictable.
Cache the Right Data
Not every query should be cached.
Good cache candidates share several characteristics:
- expensive to compute
- frequently reused
- read significantly more often than written
- safe to serve for a limited period
Poor cache candidates include rapidly changing counters, highly personalized responses, and data requiring strict transactional consistency.
| Good Candidates | Poor Candidates |
|---|---|
| Product catalog | Bank balances |
| Reference data | Inventory counters updated continuously |
| Configuration | One-time authorization codes |
| Feature flags | Active transaction state |
| Shipping rules | Frequently changing session data |
Design Good Cache Keys
Cache keys must uniquely identify the response.
Missing business attributes often produce incorrect cache sharing between users, tenants, or regions.
from dataclasses import dataclass
@dataclass(frozen=True)
class PriceKey:
tenant_id: int
product_id: str
currency: str
version: int
def redis_key(self):
return (
f"price:"
f"{self.tenant_id}:"
f"{self.product_id}:"
f"{self.currency}:"
f"v{self.version}"
)
Including entity versions allows deployments and updates to invalidate cached data without deleting entire namespaces.
Choose the Right TTL
TTL represents a business decision rather than a technical one.
Reference data may safely remain cached for several hours, while shipment tracking or inventory often requires much shorter expiration periods.
TTL should balance freshness against backend load.
Reference data 6 hours
Feature flags 10 minutes
Product catalog 5 minutes
Inventory 30 seconds
Pricing 1 minute
Adding randomized TTL jitter prevents synchronized expiration.
import random
def ttl_with_jitter(
ttl: int,
) -> int:
return ttl + random.randint(
-ttl // 10,
ttl // 10,
)
Combine Cache Layers
No individual cache layer solves every performance problem.
Distributed applications typically combine browser caching, CDNs, local application caches, and Redis.
Browser
|
CDN
|
Reverse Proxy
|
Application Memory
|
Redis
|
Database
Each layer should remove a different bottleneck rather than duplicate work performed elsewhere.
Plan for Failures
Caches should improve performance without becoming critical dependencies.
If Redis becomes unavailable, the application should degrade gracefully instead of immediately overwhelming the database.
- limit concurrent fallback queries
- apply request coalescing
- retain finite TTLs
- monitor cache hit ratio
- protect databases with rate limiting
The database should survive temporary cache failures, even if latency increases.
Common Design Choices
Different workloads benefit from different caching strategies. Rather than applying a single approach everywhere, production systems combine techniques according to consistency requirements, read/write ratios, and operational complexity.
| Decision | Recommended Choice | Typical Use Cases |
|---|---|---|
| Read strategy | Cache-Aside | Most distributed applications |
| Write strategy | Explicit invalidation | Mutable business entities |
| Expiration | TTL + Jitter | Nearly all Redis deployments |
| Distributed consistency | Outbox + Events | Microservices |
| Popular objects | Local cache + Redis | Reference data |
| Static assets | CDN + Versioned URLs | Frontend applications |
One common production mistake is replacing one strategy with another instead of combining them. For example, explicit invalidation should still be paired with finite TTLs because events can be delayed or lost.
Production Python Examples
A production cache service should encapsulate cache access behind a reusable abstraction. Business logic should not need to know whether data came from memory, Redis, or the database.
from __future__ import annotations
import json
import random
from dataclasses import asdict, dataclass
from redis.asyncio import Redis
@dataclass(frozen=True)
class Product:
product_id: str
title: str
price: float
version: int
class ProductCache:
def __init__(
self,
redis: Redis,
):
self.redis = redis
async def get(
self,
key: str,
):
value = await self.redis.get(key)
if value is None:
return None
return Product(
**json.loads(value)
)
async def put(
self,
key: str,
product: Product,
):
ttl = 300 + random.randint(
-30,
30,
)
await self.redis.set(
key,
json.dumps(
asdict(product)
),
ex=ttl,
)
async def invalidate(
self,
key: str,
):
await self.redis.delete(key)
Separating cache access into a dedicated service keeps repositories and business services independent from Redis implementation details.
Many applications also track where a response originated.
from enum import Enum
class CacheSource(str, Enum):
LOCAL = "local"
REDIS = "redis"
DATABASE = "database"
Including the cache source in logs and traces makes performance investigations significantly easier.
Production Design Example
Consider a SaaS logistics platform serving shipment tracking, pricing, customer portals, and public APIs.
The workload contains both globally shared content and tenant-specific business data. Different cache layers therefore serve different types of requests.
Users
|
Browser
|
CDN
|
Reverse Proxy
|
Application Cluster
|
+--------------------+
| Local Cache |
+--------------------+
|
Redis Cluster
|
PostgreSQL
The architecture applies caching according to workload characteristics:
- Browser: JavaScript, CSS, fonts, and images.
- CDN: Public documentation, landing pages, and downloadable assets.
- Reverse Proxy: Shared anonymous API responses.
- Local Cache: Feature flags, tenant configuration, and metadata.
- Redis: Product catalog, pricing rules, shipment summaries, and authorization metadata.
- PostgreSQL: Authoritative business state.
Updates follow a consistent workflow:
Database Commit
|
Outbox Event
|
Message Broker
|
Redis Invalidation
|
Application Local Cache
|
CDN Purge (if required)
Each cache layer receives invalidation appropriate to its responsibility, avoiding unnecessary full-cache purges.
Failure Scenarios
Redis becomes unavailable. Local caches continue serving recently used data while request coalescing limits database fallback traffic.
CDN cache is cold after deployment. Reverse proxy caching and request collapsing prevent simultaneous origin requests.
An invalidation event is delayed. Finite TTLs eventually remove stale entries even if the event is temporarily unavailable.
Traffic unexpectedly doubles. Browser, CDN, and local caches absorb most additional requests, reducing pressure on Redis and PostgreSQL.
A hot key appears. Local memory caching and request distribution prevent one Redis shard from becoming overloaded.
Monitoring
Distributed caches require observability across every layer, not only Redis.
| Metric | Purpose |
|---|---|
| Cache hit ratio | Measure cache effectiveness |
| Origin requests | Detect CDN efficiency |
| Redis latency | Identify overloaded cache nodes |
| Database fallback rate | Detect cache failures |
| Top cache keys | Find hot keys |
| Evictions | Detect insufficient memory |
| Invalidation delay | Measure stale-data window |
| Cache source distribution | Verify traffic reaches intended cache layer |
Observability should answer three questions:
- Which layer served the request?
- Why was the cache missed?
- How much backend work was avoided?
Common Mistakes
| Mistake | Production Impact | Better Approach |
|---|---|---|
| Caching every database query | Low hit ratio and wasted memory | Cache only expensive, frequently reused data |
| Using incomplete cache keys | Incorrect data shared between users or tenants | Include every business attribute affecting the response |
| Using identical TTLs everywhere | Synchronized expiration causes request spikes | Apply workload-specific TTLs with jitter |
| Removing TTL because invalidation exists | Lost events leave stale entries indefinitely | Always keep finite expiration |
| Purging the entire cache after every deployment | Cold cache dramatically increases backend load | Use versioned keys and progressive warm-up |
| Using Redis for static assets | Unnecessary infrastructure cost | Serve static content through a CDN |
| Ignoring local application caching | Redis receives unnecessary repeated requests | Cache small shared metadata in process memory |
| Allowing unlimited database fallback | Cache outage becomes database outage | Use request coalescing, bounded concurrency, and rate limiting |
| Monitoring only cache hit ratio | Hot keys and invalidation delays remain hidden | Monitor latency, top keys, evictions, and fallback traffic |
| Treating Redis as authoritative storage | Data inconsistency after cache failures | Keep the database as the source of truth |
| Ignoring serialization compatibility | Deployments fail when reading cached objects | Version cache payloads and namespaces |
| Skipping load testing | Production traffic exposes hidden bottlenecks | Test cold starts, cache failures, and traffic spikes |
Production Checklist
- Cache only data with measurable performance benefits.
- Keep the database as the authoritative source.
- Design deterministic, versioned cache keys.
- Use workload-specific TTLs with randomized jitter.
- Apply Cache-Aside for most read-heavy workloads.
- Combine browser, CDN, local, and distributed caches.
- Invalidate caches only after successful database commits.
- Use transactional outbox events for distributed invalidation.
- Protect the database with request coalescing and bounded fallback.
- Monitor hit ratio, latency, evictions, and hot keys.
- Track which cache layer served every request.
- Test Redis outages and cold-cache deployments.
- Version serialized cache objects.
- Document freshness requirements for every cached dataset.
- Review cache effectiveness regularly as workloads evolve.
Conclusion
Effective caching is an architectural discipline rather than a single technology choice. Distributed systems achieve the best results by assigning each cache layer a clear responsibility, designing reliable cache keys, selecting appropriate expiration policies, and planning for failures before they occur.
The most successful production architectures combine multiple cache layers, explicit invalidation, finite TTLs, request coalescing, and comprehensive observability. Together, these techniques improve scalability while preserving correctness during failures, deployments, and traffic spikes.
Key Takeaway: Treat caching as part of the system architecture, not as an isolated optimization. Cache only valuable data, invalidate predictably, monitor every cache layer, and ensure the application continues operating safely even when caches fail.
Comments (0)