Caching Best Practices for Distributed Applications

By Oleksandr Andrushchenko — Published on
0 Likes
0 Dislikes
Caching Best Practices for Distributed Applications
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

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.

Author

Enjoyed this article?

Support Oleksandr Andrushchenko

Buy me a coffee

This helps Oleksandr Andrushchenko continue creating useful content

Related articles

Comments (0)